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/main.js CHANGED
@@ -1868,7 +1868,7 @@ var exports_version = {};
1868
1868
  __export(exports_version, {
1869
1869
  NEGOTIUM_VERSION: () => NEGOTIUM_VERSION
1870
1870
  });
1871
- var NEGOTIUM_VERSION = "0.2.26";
1871
+ var NEGOTIUM_VERSION = "0.2.28";
1872
1872
 
1873
1873
  // ../../packages/core/src/agents/codex-native-multi-agent.ts
1874
1874
  import { spawn } from "child_process";
@@ -7506,8 +7506,11 @@ var init_constants = __esm(() => {
7506
7506
  var exports_api_topics = {};
7507
7507
  __export(exports_api_topics, {
7508
7508
  upsertTopic: () => upsertTopic,
7509
+ stampUnscopedOtiumTopics: () => stampUnscopedOtiumTopics,
7509
7510
  setTopicSurfaces: () => setTopicSurfaces,
7510
7511
  setTopicSessionId: () => setTopicSessionId,
7512
+ setSurfaceScopeRequired: () => setSurfaceScopeRequired,
7513
+ setDefaultSurfaceScope: () => setDefaultSurfaceScope,
7511
7514
  setApiTopicAgent: () => setApiTopicAgent,
7512
7515
  revokeSubagentTellTarget: () => revokeSubagentTellTarget,
7513
7516
  reparentTopicChildren: () => reparentTopicChildren,
@@ -7516,10 +7519,12 @@ __export(exports_api_topics, {
7516
7519
  normalizeTopicSurface: () => normalizeTopicSurface,
7517
7520
  normalizeTopicState: () => normalizeTopicState,
7518
7521
  normalizeTopicKind: () => normalizeTopicKind,
7522
+ normalizeSurfaceScope: () => normalizeSurfaceScope,
7519
7523
  normalizeAiMode: () => normalizeAiMode,
7520
7524
  listTopics: () => listTopics,
7521
7525
  listSubagentTellTargetIds: () => listSubagentTellTargetIds,
7522
7526
  isTopicVisible: () => isTopicVisible,
7527
+ isSurfaceScopeRequired: () => isSurfaceScopeRequired,
7523
7528
  inferTopicKind: () => inferTopicKind,
7524
7529
  inferAiMode: () => inferAiMode,
7525
7530
  grantSubagentTellTarget: () => grantSubagentTellTarget,
@@ -7533,6 +7538,7 @@ __export(exports_api_topics, {
7533
7538
  findTopicTitleConflict: () => findTopicTitleConflict,
7534
7539
  deleteTopic: () => deleteTopic,
7535
7540
  defaultTopicSurface: () => defaultTopicSurface,
7541
+ defaultSurfaceScope: () => defaultSurfaceScope,
7536
7542
  clearTopicSessionId: () => clearTopicSessionId,
7537
7543
  aiMentionFromMode: () => aiMentionFromMode,
7538
7544
  addParticipantToDB: () => addParticipantToDB
@@ -7543,6 +7549,26 @@ function defaultTopicSurface() {
7543
7549
  function normalizeTopicSurface(value) {
7544
7550
  return value === "telegram" || value === "otium" || value === "terminal" ? value : "terminal";
7545
7551
  }
7552
+ function normalizeSurfaceScope(value) {
7553
+ if (typeof value !== "string")
7554
+ return null;
7555
+ const trimmed = value.trim();
7556
+ return trimmed ? trimmed : null;
7557
+ }
7558
+ function defaultSurfaceScope() {
7559
+ return activeSurfaceScope ?? normalizeSurfaceScope(process.env.NEGOTIUM_SURFACE_SCOPE);
7560
+ }
7561
+ function setDefaultSurfaceScope(scope) {
7562
+ const previous = activeSurfaceScope;
7563
+ activeSurfaceScope = normalizeSurfaceScope(scope);
7564
+ return previous;
7565
+ }
7566
+ function setSurfaceScopeRequired(required) {
7567
+ surfaceScopeRequired = required;
7568
+ }
7569
+ function isSurfaceScopeRequired() {
7570
+ return surfaceScopeRequired;
7571
+ }
7546
7572
  function tableColumns2(table) {
7547
7573
  const rows = db.query(`PRAGMA table_info(${table})`).all();
7548
7574
  return new Set(rows.map((row) => row.name));
@@ -7674,6 +7700,7 @@ function initializeApiTopicsSchema() {
7674
7700
  is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
7675
7701
  visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
7676
7702
  surface TEXT NOT NULL DEFAULT 'terminal' CHECK (surface IN ('terminal','telegram','otium')),
7703
+ surface_scope TEXT,
7677
7704
  browser_profile TEXT NOT NULL DEFAULT 'default',
7678
7705
  browser_profile_owner TEXT,
7679
7706
  session_id TEXT,
@@ -7719,8 +7746,8 @@ function initializeApiTopicsSchema() {
7719
7746
  const legacyBaseEffort = row.base_effort ?? row.default_effort;
7720
7747
  db.query(`INSERT INTO api_topics_next
7721
7748
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
7722
- created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,session_id)
7723
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(String(row.id), String(row.title), normalized.kind, typeof row.description === "string" ? row.description : null, normalized.agent ?? null, typeof legacyBaseModel === "string" ? legacyBaseModel : null, typeof legacyBaseEffort === "string" ? legacyBaseEffort : null, normalized.aiMode, String(row.created_at), typeof row.last_message_at === "string" ? row.last_message_at : null, typeof row.parent_topic_id === "string" ? row.parent_topic_id : null, typeof row.memory_topic_id === "string" ? row.memory_topic_id : null, typeof row.memory_key === "string" ? row.memory_key : null, Number(row.is_fork ?? 0) !== 0 ? 1 : 0, Number(row.is_subagent ?? 0) !== 0 ? 1 : 0, row.visibility === "hidden" ? "hidden" : "visible", row.surface === undefined ? defaultTopicSurface() : normalizeTopicSurface(row.surface), typeof row.session_id === "string" ? row.session_id : null);
7749
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,surface_scope,session_id)
7750
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(String(row.id), String(row.title), normalized.kind, typeof row.description === "string" ? row.description : null, normalized.agent ?? null, typeof legacyBaseModel === "string" ? legacyBaseModel : null, typeof legacyBaseEffort === "string" ? legacyBaseEffort : null, normalized.aiMode, String(row.created_at), typeof row.last_message_at === "string" ? row.last_message_at : null, typeof row.parent_topic_id === "string" ? row.parent_topic_id : null, typeof row.memory_topic_id === "string" ? row.memory_topic_id : null, typeof row.memory_key === "string" ? row.memory_key : null, Number(row.is_fork ?? 0) !== 0 ? 1 : 0, Number(row.is_subagent ?? 0) !== 0 ? 1 : 0, row.visibility === "hidden" ? "hidden" : "visible", row.surface === undefined ? defaultTopicSurface() : normalizeTopicSurface(row.surface), normalizeSurfaceScope(row.surface_scope), typeof row.session_id === "string" ? row.session_id : null);
7724
7751
  }
7725
7752
  db.exec("DROP TABLE IF EXISTS topic_members");
7726
7753
  db.exec("DROP TABLE api_topics");
@@ -7777,6 +7804,9 @@ function initializeApiTopicsSchema() {
7777
7804
  if (!tableColumns2("api_topics").has("surface")) {
7778
7805
  db.exec("ALTER TABLE api_topics ADD COLUMN surface TEXT NOT NULL DEFAULT 'terminal'");
7779
7806
  }
7807
+ if (!tableColumns2("api_topics").has("surface_scope")) {
7808
+ db.exec("ALTER TABLE api_topics ADD COLUMN surface_scope TEXT");
7809
+ }
7780
7810
  if (tableColumns2("api_topics").has("access_mode")) {
7781
7811
  try {
7782
7812
  db.exec("ALTER TABLE api_topics DROP COLUMN access_mode");
@@ -7812,6 +7842,30 @@ function initializeApiTopicsSchema() {
7812
7842
  backfillTopicSurfaces();
7813
7843
  db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_last_message ON api_topics(last_message_at DESC)");
7814
7844
  db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_surface ON api_topics(surface)");
7845
+ db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_surface_scope ON api_topics(surface, surface_scope)");
7846
+ }
7847
+ function stampUnscopedOtiumTopics(scope) {
7848
+ const normalized = normalizeSurfaceScope(scope);
7849
+ if (!normalized)
7850
+ return 0;
7851
+ db.exec(`
7852
+ CREATE TABLE IF NOT EXISTS api_schema_migrations (
7853
+ key TEXT PRIMARY KEY,
7854
+ applied_at TEXT NOT NULL
7855
+ )
7856
+ `);
7857
+ const applied = db.query("SELECT key FROM api_schema_migrations WHERE key = ?").get(SURFACE_SCOPE_STAMP_MIGRATION);
7858
+ if (applied)
7859
+ return 0;
7860
+ let stamped = 0;
7861
+ db.transaction(() => {
7862
+ stamped = Number(db.query("UPDATE api_topics SET surface_scope = ? WHERE surface = 'otium' AND surface_scope IS NULL").run(normalized).changes ?? 0);
7863
+ db.query("INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)").run(SURFACE_SCOPE_STAMP_MIGRATION, new Date().toISOString());
7864
+ })();
7865
+ if (stamped > 0) {
7866
+ logger.info({ scope: normalized, stamped }, "api_topics: surface scope stamped");
7867
+ }
7868
+ return stamped;
7815
7869
  }
7816
7870
  function backfillTopicSurfaces() {
7817
7871
  db.exec(`
@@ -7832,22 +7886,24 @@ function backfillTopicSurfaces() {
7832
7886
  logger.info({ surface }, "api_topics: surface backfilled");
7833
7887
  }
7834
7888
  function renameSurfaceTitleCollisions() {
7835
- const rows = db.query("SELECT id, title, kind, surface FROM api_topics ORDER BY created_at ASC, rowid ASC").all();
7836
- const taken = new Set;
7889
+ const rows = db.query("SELECT id, title, kind, surface FROM api_topics WHERE kind != 'manager' ORDER BY created_at ASC, rowid ASC").all();
7890
+ const keyOf = (surface, kind, title) => [surface, kind, normalizedTitle(title)].join("\x00");
7891
+ const reserved = new Set(rows.map((row) => keyOf(row.surface, row.kind, row.title)));
7892
+ const used = new Set;
7837
7893
  const update = db.query("UPDATE api_topics SET title = ? WHERE id = ?");
7838
7894
  for (const row of rows) {
7839
- const key = (title) => [row.surface, row.kind, normalizedTitle(title)].join("\x00");
7840
- if (!taken.has(key(row.title))) {
7841
- taken.add(key(row.title));
7895
+ const key = (title) => keyOf(row.surface, row.kind, title);
7896
+ if (!used.has(key(row.title))) {
7897
+ used.add(key(row.title));
7842
7898
  continue;
7843
7899
  }
7844
7900
  let suffix = 2;
7845
7901
  let candidate = `${row.title} (${suffix})`;
7846
- while (taken.has(key(candidate))) {
7902
+ while (reserved.has(key(candidate)) || used.has(key(candidate))) {
7847
7903
  suffix += 1;
7848
7904
  candidate = `${row.title} (${suffix})`;
7849
7905
  }
7850
- taken.add(key(candidate));
7906
+ used.add(key(candidate));
7851
7907
  update.run(candidate, row.id);
7852
7908
  logger.warn({ topicId: row.id, surface: row.surface, from: row.title, to: candidate }, "api_topics: renamed a duplicate title for surface-scoped uniqueness");
7853
7909
  }
@@ -7910,7 +7966,8 @@ function rowToDto2(r, participants = getTopicParticipants(r.id), tellTargets) {
7910
7966
  subagentReportMode: r.subagent_report_mode === "tell" || r.subagent_report_mode === "status-only" ? r.subagent_report_mode : "auto"
7911
7967
  } : {},
7912
7968
  visibility: normalizeTopicVisibility(r.visibility),
7913
- surface: normalizeTopicSurface(r.surface)
7969
+ surface: normalizeTopicSurface(r.surface),
7970
+ surfaceScope: normalizeSurfaceScope(r.surface_scope)
7914
7971
  };
7915
7972
  }
7916
7973
  function normalizeTopicVisibility(value) {
@@ -7978,6 +8035,13 @@ function normalizeTopicState(input) {
7978
8035
  function normalizedTitle(title) {
7979
8036
  return title.trim().toLowerCase();
7980
8037
  }
8038
+ function surfaceScopeForWrite(t) {
8039
+ if (normalizeTopicSurface(t.surface ?? defaultTopicSurface()) !== "otium")
8040
+ return null;
8041
+ if (t.surfaceScope !== undefined)
8042
+ return normalizeSurfaceScope(t.surfaceScope);
8043
+ return defaultSurfaceScope();
8044
+ }
7981
8045
  function upsertTopic(t) {
7982
8046
  const normalized = normalizeTopicState({
7983
8047
  id: t.id,
@@ -7990,8 +8054,8 @@ function upsertTopic(t) {
7990
8054
  db.query(`INSERT INTO api_topics
7991
8055
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
7992
8056
  created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
7993
- subagent_report_mode)
7994
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
8057
+ surface_scope,subagent_report_mode)
8058
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
7995
8059
  ON CONFLICT(id) DO UPDATE SET
7996
8060
  title = excluded.title,
7997
8061
  kind = excluded.kind,
@@ -8009,7 +8073,11 @@ function upsertTopic(t) {
8009
8073
  is_subagent = excluded.is_subagent,
8010
8074
  visibility = excluded.visibility,
8011
8075
  surface = excluded.surface,
8012
- subagent_report_mode = excluded.subagent_report_mode`).run(t.id, t.title, normalized.kind, t.description ?? null, normalized.agent ?? null, t.defaultModel ?? null, t.defaultEffort ?? null, normalized.aiMode, t.createdAt, t.lastMessageAt ?? null, t.parentTopicId ?? null, t.memoryTopicId ?? null, t.memoryKey ?? null, t.isFork ? 1 : 0, t.isSubagent ? 1 : 0, normalizeTopicVisibility(t.visibility), normalizeTopicSurface(t.surface ?? defaultTopicSurface()), t.subagentReportMode ?? "auto");
8076
+ -- A room's workspace is fixed at creation (M-1). COALESCE, not
8077
+ -- assignment: an update may fill in a scope that was unknown when the
8078
+ -- room was created, but may never move a room to another workspace.
8079
+ surface_scope = COALESCE(api_topics.surface_scope, excluded.surface_scope),
8080
+ subagent_report_mode = excluded.subagent_report_mode`).run(t.id, t.title, normalized.kind, t.description ?? null, normalized.agent ?? null, t.defaultModel ?? null, t.defaultEffort ?? null, normalized.aiMode, t.createdAt, t.lastMessageAt ?? null, t.parentTopicId ?? null, t.memoryTopicId ?? null, t.memoryKey ?? null, t.isFork ? 1 : 0, t.isSubagent ? 1 : 0, normalizeTopicVisibility(t.visibility), normalizeTopicSurface(t.surface ?? defaultTopicSurface()), surfaceScopeForWrite(t), t.subagentReportMode ?? "auto");
8013
8081
  db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
8014
8082
  for (const participant of t.participants) {
8015
8083
  db.query("INSERT INTO topic_members (topic_id,user_id,role) VALUES (?,?,?)").run(t.id, participant.userId, participant.role);
@@ -8023,7 +8091,8 @@ function upsertTopic(t) {
8023
8091
  })();
8024
8092
  }
8025
8093
  function listTopics(opts = {}) {
8026
- const rows = opts.surface ? db.query("SELECT * FROM api_topics WHERE surface = ? ORDER BY last_message_at DESC").all(normalizeTopicSurface(opts.surface)) : db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all();
8094
+ const scoped = Object.hasOwn(opts, "surfaceScope");
8095
+ const rows = opts.surface ? db.query(`SELECT * FROM api_topics WHERE surface = ?${scoped ? " AND surface_scope IS ?" : ""} ORDER BY last_message_at DESC`).all(...scoped ? [normalizeTopicSurface(opts.surface), normalizeSurfaceScope(opts.surfaceScope)] : [normalizeTopicSurface(opts.surface)]) : db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all();
8027
8096
  const participants = getAllTopicParticipants();
8028
8097
  const tellTargets = rows.some((row) => row.is_subagent !== 0) ? getAllSubagentTellTargets() : undefined;
8029
8098
  return rows.map((row) => rowToDto2(row, participants.get(row.id) ?? [], tellTargets));
@@ -8032,15 +8101,24 @@ function getTopic(id) {
8032
8101
  const r = db.query("SELECT * FROM api_topics WHERE id = ?").get(id);
8033
8102
  return r ? rowToDto2(r) : null;
8034
8103
  }
8035
- function getManagerTopicForUser(userId) {
8036
- const row = db.query(`SELECT t.* FROM api_topics t
8104
+ function getManagerTopicForUser(userId, surface, opts = {}) {
8105
+ const scoped = Object.hasOwn(opts, "surfaceScope");
8106
+ const sql = `SELECT t.* FROM api_topics t
8037
8107
  JOIN topic_members m ON m.topic_id = t.id
8038
8108
  WHERE t.kind = 'manager'
8039
8109
  AND t.id != ?
8040
8110
  AND m.user_id = ?
8041
8111
  AND m.role = 'owner'
8112
+ ${surface ? "AND t.surface = ?" : ""}
8113
+ ${scoped ? "AND t.surface_scope IS ?" : ""}
8042
8114
  ORDER BY t.created_at ASC
8043
- LIMIT 1`).get(GENERAL_TOPIC_ID, userId);
8115
+ LIMIT 1`;
8116
+ const params = [GENERAL_TOPIC_ID, userId];
8117
+ if (surface)
8118
+ params.push(surface);
8119
+ if (scoped)
8120
+ params.push(normalizeSurfaceScope(opts.surfaceScope));
8121
+ const row = db.query(sql).get(...params);
8044
8122
  return row ? rowToDto2(row) : null;
8045
8123
  }
8046
8124
  function getTopicMemoryOrigin(id) {
@@ -8075,18 +8153,17 @@ function getTopicByNameAndKind(title, kind) {
8075
8153
  function findTopicTitleConflict(title, kind, opts = {}) {
8076
8154
  const wanted = normalizedTitle(title);
8077
8155
  const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
8156
+ const surfaceScope = Object.hasOwn(opts, "surfaceScope") ? normalizeSurfaceScope(opts.surfaceScope) : surface === "otium" ? defaultSurfaceScope() : null;
8078
8157
  const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
8079
8158
  if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
8080
8159
  const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID);
8081
8160
  if (general)
8082
8161
  return rowToDto2(general);
8083
8162
  }
8084
- const params = [wanted, surface];
8085
- let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ?";
8086
- if (kind !== "manager") {
8087
- sql += " AND (kind = ? OR id = ?)";
8088
- params.push(kind, GENERAL_TOPIC_ID);
8089
- }
8163
+ if (kind === "manager")
8164
+ return null;
8165
+ const params = [wanted, surface, surfaceScope, kind, GENERAL_TOPIC_ID];
8166
+ let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ? AND surface_scope IS ? AND (kind = ? OR id = ?)";
8090
8167
  if (opts.excludeTopicId) {
8091
8168
  sql += " AND id != ?";
8092
8169
  params.push(opts.excludeTopicId);
@@ -8113,14 +8190,16 @@ function getTopicByNameForUser(title, userId, opts = {}) {
8113
8190
  const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
8114
8191
  const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
8115
8192
  const requestedTitle = qualified ? qualified[2].trim() : trimmed;
8193
+ const scoped = Object.hasOwn(opts, "surfaceScope");
8116
8194
  const rows = db.query(`SELECT t.* FROM api_topics t
8117
8195
  WHERE LOWER(t.title) = LOWER(?)
8118
8196
  AND t.id != ?
8119
8197
  AND t.visibility != 'hidden'
8120
8198
  AND (? IS NULL OR t.surface = ?)
8199
+ ${scoped ? "AND t.surface_scope IS ?" : ""}
8121
8200
  AND EXISTS (
8122
8201
  SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
8123
- )`).all(requestedTitle, GENERAL_TOPIC_ID, opts.surface ?? null, opts.surface ?? null, userId);
8202
+ )`).all(requestedTitle, GENERAL_TOPIC_ID, opts.surface ?? null, opts.surface ?? null, ...scoped ? [normalizeSurfaceScope(opts.surfaceScope)] : [], userId);
8124
8203
  const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
8125
8204
  return matches.length === 1 ? rowToDto2(matches[0]) : null;
8126
8205
  }
@@ -8208,7 +8287,7 @@ function removeParticipantFromDB(topicId, userId) {
8208
8287
  db.query("DELETE FROM topic_members WHERE topic_id = ? AND user_id = ?").run(topicId, userId);
8209
8288
  return true;
8210
8289
  }
8211
- var DEFAULT_AGENT_ROOM_AGENT = "maestro", SURFACE_BACKFILL_MIGRATION = "api_topics_surface_backfill_20260808";
8290
+ var DEFAULT_AGENT_ROOM_AGENT = "maestro", activeSurfaceScope = null, surfaceScopeRequired = false, SURFACE_SCOPE_STAMP_MIGRATION = "api_topics_surface_scope_stamp_20260809", SURFACE_BACKFILL_MIGRATION = "api_topics_surface_backfill_20260808";
8212
8291
  var init_api_topics = __esm(async () => {
8213
8292
  init_constants();
8214
8293
  init_logger();
@@ -11428,8 +11507,10 @@ var init_active_rooms = __esm(async () => {
11428
11507
 
11429
11508
  // ../../packages/core/src/topics/personal-general.ts
11430
11509
  import { randomUUID as randomUUID9 } from "crypto";
11431
- function ensurePersonalGeneral(userId) {
11432
- const existing = getManagerTopicForUser(userId);
11510
+ function ensurePersonalGeneral(userId, surface, opts = {}) {
11511
+ const scope = normalizeTopicSurface(surface ?? defaultTopicSurface());
11512
+ const surfaceScope = opts.surfaceScope !== undefined ? normalizeSurfaceScope(opts.surfaceScope) : scope === "otium" ? defaultSurfaceScope() : null;
11513
+ const existing = getManagerTopicForUser(userId, scope, { surfaceScope });
11433
11514
  if (existing) {
11434
11515
  if (existing.description === LEGACY_PERSONAL_GENERAL_DESCRIPTION) {
11435
11516
  existing.description = PERSONAL_GENERAL_DESCRIPTION;
@@ -11453,6 +11534,8 @@ function ensurePersonalGeneral(userId) {
11453
11534
  aiMode: "always",
11454
11535
  aiMention: false,
11455
11536
  participants: [{ userId, role: "owner" }],
11537
+ surface: scope,
11538
+ surfaceScope,
11456
11539
  createdAt: now,
11457
11540
  lastMessageAt: now
11458
11541
  };
@@ -13440,7 +13523,8 @@ function mergeRuntimeUserTurnRequest(input) {
13440
13523
  const now = Date.now();
13441
13524
  return db.transaction(() => {
13442
13525
  const rows = db.query("SELECT * FROM runtime_user_turn_requests WHERE topic_id = ? ORDER BY created_at ASC, rowid ASC").all(input.topicId);
13443
- const previous = rows.map(rowToRequest);
13526
+ const thread = input.execution.threadRootId;
13527
+ const previous = rows.map(rowToRequest).filter((request) => request.execution?.threadRootId === thread);
13444
13528
  const omittedRequestIds = new Set([
13445
13529
  ...input.omitRequestIds ?? [],
13446
13530
  ...previous.filter((request) => Boolean(request.execution?.providerSessionId)).map((request) => request.requestId)
@@ -13476,7 +13560,11 @@ function mergeRuntimeUserTurnRequest(input) {
13476
13560
  execution.sessionIdSpecified = true;
13477
13561
  }
13478
13562
  const attachments = flattenUserTurnAttachments(userMessages);
13479
- db.query("DELETE FROM runtime_user_turn_requests WHERE topic_id = ?").run(input.topicId);
13563
+ const absorbed = previous.map((request) => request.requestId);
13564
+ if (absorbed.length > 0) {
13565
+ db.query(`DELETE FROM runtime_user_turn_requests
13566
+ WHERE topic_id = ? AND request_id IN (${absorbed.map(() => "?").join(",")})`).run(input.topicId, ...absorbed);
13567
+ }
13480
13568
  db.query(`INSERT INTO runtime_user_turn_requests
13481
13569
  (request_id, topic_id, user_id, prompt, user_messages_json, attachments_json,
13482
13570
  allow_auto_continue, execution_json, topic_epoch, created_at,
@@ -14323,8 +14411,8 @@ function updateTopic(topicId, patch) {
14323
14411
  function isParticipant(topic, userId) {
14324
14412
  return topic.participants.some((p) => p.userId === userId);
14325
14413
  }
14326
- function nextDerivedTopicTitle(sourceTitle, kind, suffix, surface) {
14327
- const visibleTitles = new Set(listTopics(surface ? { surface } : {}).filter((topic) => topic.kind === kind).map((topic) => topic.title.toLowerCase()));
14414
+ function nextDerivedTopicTitle(sourceTitle, kind, suffix, surface, surfaceScope) {
14415
+ const visibleTitles = new Set(listTopics(surface ? { surface, surfaceScope: surfaceScope ?? null } : {}).filter((topic) => topic.kind === kind).map((topic) => topic.title.toLowerCase()));
14328
14416
  let n = 1;
14329
14417
  let title = `${sourceTitle}-${suffix}-${n}`;
14330
14418
  while (visibleTitles.has(title.toLowerCase())) {
@@ -14389,8 +14477,9 @@ async function createDerivedTopicImpl(topic, sourceTopicId, userId, copyHistory,
14389
14477
  ] : [{ userId, role: "owner" }];
14390
14478
  const kind = topic.kind ?? inferTopicKind(topic);
14391
14479
  const surface = topic.surface ?? defaultTopicSurface();
14392
- const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface);
14393
- const conflict = findTopicTitleConflict(title, kind, { surface });
14480
+ const surfaceScope = topic.surfaceScope ?? null;
14481
+ const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface, surfaceScope);
14482
+ const conflict = findTopicTitleConflict(title, kind, { surface, surfaceScope });
14394
14483
  if (conflict) {
14395
14484
  logger.info({ sourceTopicId, title, kind, conflictTopicId: conflict.id }, "createDerivedTopic: title conflict");
14396
14485
  throw new TopicTitleConflictError(title);
@@ -14413,7 +14502,8 @@ async function createDerivedTopicImpl(topic, sourceTopicId, userId, copyHistory,
14413
14502
  isFork: copyHistory,
14414
14503
  ...subagent ? { isSubagent: true } : {},
14415
14504
  visibility: topic.visibility,
14416
- surface
14505
+ surface,
14506
+ surfaceScope
14417
14507
  };
14418
14508
  let sessionId;
14419
14509
  let rollbackHandle;
@@ -14521,7 +14611,10 @@ async function createDerivedTopicImpl(topic, sourceTopicId, userId, copyHistory,
14521
14611
  if (!currentSource || currentSource.kind === "manager" || !isParticipant(currentSource, userId) || subagent && isRuntimeTopicMaintenance(sourceTopicId)) {
14522
14612
  throw new TopicDeriveBusyError("Source topic changed while deriving; try again");
14523
14613
  }
14524
- const transactionalConflict = findTopicTitleConflict(title, kind, { surface });
14614
+ const transactionalConflict = findTopicTitleConflict(title, kind, {
14615
+ surface,
14616
+ surfaceScope
14617
+ });
14525
14618
  if (transactionalConflict)
14526
14619
  throw new TopicTitleConflictError(title);
14527
14620
  upsertTopic(derived);
@@ -15391,13 +15484,14 @@ async function forwardToPeer(args) {
15391
15484
  error: forwarded.configured ? "remote session bridge is configured but unavailable" : "remote nodes are not connected on this negotium node (standalone mode)"
15392
15485
  };
15393
15486
  }
15394
- async function peerSessionsForUser(userId, sourceQueryId) {
15487
+ async function peerSessionsForUser(userId, sourceQueryId, fromTopicId) {
15395
15488
  if (activeBridge)
15396
- return activeBridge.sessions(userId, sourceQueryId);
15489
+ return activeBridge.sessions(userId, sourceQueryId, fromTopicId);
15397
15490
  const sessions = await callLoopbackBridge({
15398
15491
  action: "sessions",
15399
15492
  userId,
15400
- sourceQueryId
15493
+ sourceQueryId,
15494
+ fromTopicId
15401
15495
  });
15402
15496
  if (sessions.result)
15403
15497
  return sessions.result;
@@ -16153,6 +16247,226 @@ body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--
16153
16247
  </style>`;
16154
16248
  });
16155
16249
 
16250
+ // ../../packages/core/src/storage/token-stats.ts
16251
+ var exports_token_stats = {};
16252
+ __export(exports_token_stats, {
16253
+ tokenStatsFileId: () => tokenStatsFileId,
16254
+ recordUsage: () => recordUsage,
16255
+ getTopicStats: () => getTopicStats,
16256
+ getStats: () => getStats,
16257
+ deleteTopicStats: () => deleteTopicStats,
16258
+ calcCost: () => calcCost
16259
+ });
16260
+ import { createHash as createHash7 } from "crypto";
16261
+ import { mkdirSync as mkdirSync16, writeFileSync as writeFileSync14 } from "fs";
16262
+ import { join as join25 } from "path";
16263
+ function emptyBucket() {
16264
+ return {
16265
+ inputTokens: 0,
16266
+ outputTokens: 0,
16267
+ cacheCreationInputTokens: 0,
16268
+ cacheReadInputTokens: 0,
16269
+ queries: 0,
16270
+ estimatedCostUsd: 0
16271
+ };
16272
+ }
16273
+ function tokenStatsFileId(userId) {
16274
+ const rawUserId = String(userId);
16275
+ return /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,255}$/.test(rawUserId) && !rawUserId.includes("..") ? rawUserId : `sha256-${createHash7("sha256").update(rawUserId).digest("hex")}`;
16276
+ }
16277
+ function queriesPath(userId) {
16278
+ const fileId = tokenStatsFileId(userId);
16279
+ const logDir = resolveStorageLogDir();
16280
+ mkdirSync16(logDir, { recursive: true });
16281
+ return join25(logDir, `token-queries-${fileId}.jsonl`);
16282
+ }
16283
+ function loadRecords(userId) {
16284
+ try {
16285
+ return readJsonlLines(queriesPath(userId)).flatMap((line) => {
16286
+ try {
16287
+ return [JSON.parse(line)];
16288
+ } catch {
16289
+ return [];
16290
+ }
16291
+ });
16292
+ } catch {
16293
+ return [];
16294
+ }
16295
+ }
16296
+ function calcCost(b) {
16297
+ return b.estimatedCostUsd;
16298
+ }
16299
+ function estimateUsageCost(agent, model, usage) {
16300
+ const prices = TOKEN_PRICES[`${agent}:${model}`];
16301
+ if (!prices)
16302
+ return 0;
16303
+ return (usage.inputTokens * prices.input + usage.outputTokens * prices.output + (agent === "claude" ? usage.cacheCreationInputTokens * (prices.cacheWrite ?? prices.input) : 0) + usage.cacheReadInputTokens * prices.cacheRead) / 1e6;
16304
+ }
16305
+ function isQueryRecord(value) {
16306
+ if (!value || typeof value !== "object")
16307
+ return false;
16308
+ const record = value;
16309
+ return record.schemaVersion === 2 && typeof record.timestamp === "string" && typeof record.session === "string" && typeof record.topicId === "string" && typeof record.agent === "string" && typeof record.model === "string" && typeof record.inputTokens === "number" && typeof record.outputTokens === "number" && typeof record.cacheCreationInputTokens === "number" && typeof record.cacheReadInputTokens === "number" && typeof record.estimatedCostUsd === "number";
16310
+ }
16311
+ function recordUsage(userId, session, usage, context) {
16312
+ const cacheReadInputTokens = usage.cacheReadInputTokens ?? 0;
16313
+ const inputTokens = context.agent === "claude" ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheReadInputTokens);
16314
+ const normalized = {
16315
+ inputTokens,
16316
+ outputTokens: usage.outputTokens,
16317
+ cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
16318
+ cacheReadInputTokens
16319
+ };
16320
+ const record = {
16321
+ schemaVersion: 2,
16322
+ timestamp: new Date().toISOString(),
16323
+ session,
16324
+ topicId: context.topicId,
16325
+ ...context.providerSessionId ? { providerSessionId: context.providerSessionId } : {},
16326
+ agent: context.agent,
16327
+ model: context.model,
16328
+ ...normalized,
16329
+ ...usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {},
16330
+ ...usage.contextWindow !== undefined ? { contextWindow: usage.contextWindow } : {},
16331
+ estimatedCostUsd: usage.costUsd ?? estimateUsageCost(context.agent, context.model, normalized)
16332
+ };
16333
+ try {
16334
+ appendJsonlEntry(queriesPath(userId), record);
16335
+ } catch (e) {
16336
+ logger.warn({ err: e, userId }, "token-stats: Failed to record");
16337
+ }
16338
+ }
16339
+ function deleteTopicStats(userId, topicId) {
16340
+ const path = queriesPath(userId);
16341
+ try {
16342
+ const kept = readJsonlLines(path).filter((line) => {
16343
+ try {
16344
+ const record = JSON.parse(line);
16345
+ return record.topicId !== topicId;
16346
+ } catch {
16347
+ return true;
16348
+ }
16349
+ });
16350
+ writeFileSync14(path, kept.length > 0 ? `${kept.join(`
16351
+ `)}
16352
+ ` : "", "utf-8");
16353
+ } catch (e) {
16354
+ if (e.code === "ENOENT")
16355
+ return;
16356
+ logger.warn({ err: e, userId, topicId }, "token-stats: Failed to delete topic stats");
16357
+ }
16358
+ }
16359
+ function getStats(userId, from, to) {
16360
+ const records = loadRecords(userId);
16361
+ const fromTs = from ? new Date(from).getTime() : 0;
16362
+ const toTs = to ? new Date(to).getTime() : Infinity;
16363
+ if (from && Number.isNaN(fromTs) || to && Number.isNaN(toTs)) {
16364
+ logger.warn({ from, to }, "token-stats: Invalid date range, returning empty");
16365
+ return {
16366
+ total: emptyBucket(),
16367
+ byHour: {},
16368
+ bySession: {},
16369
+ currentSessions: [],
16370
+ ignoredLegacyRecords: 0,
16371
+ estimatedCostUsd: 0
16372
+ };
16373
+ }
16374
+ const total = emptyBucket();
16375
+ const byHour = {};
16376
+ const bySession = {};
16377
+ const currentSessions = new Map;
16378
+ let ignoredLegacyRecords = 0;
16379
+ for (const raw of records) {
16380
+ if (!isQueryRecord(raw)) {
16381
+ ignoredLegacyRecords += 1;
16382
+ continue;
16383
+ }
16384
+ const r = raw;
16385
+ const ts = new Date(r.timestamp).getTime();
16386
+ if (ts < fromTs || ts > toTs)
16387
+ continue;
16388
+ const hourKey = r.timestamp.slice(0, 13);
16389
+ if (!byHour[hourKey])
16390
+ byHour[hourKey] = emptyBucket();
16391
+ if (!bySession[r.session])
16392
+ bySession[r.session] = emptyBucket();
16393
+ for (const bucket of [total, byHour[hourKey], bySession[r.session]]) {
16394
+ bucket.inputTokens += r.inputTokens;
16395
+ bucket.outputTokens += r.outputTokens;
16396
+ bucket.cacheCreationInputTokens += r.cacheCreationInputTokens;
16397
+ bucket.cacheReadInputTokens += r.cacheReadInputTokens;
16398
+ bucket.queries += 1;
16399
+ bucket.estimatedCostUsd += r.estimatedCostUsd;
16400
+ }
16401
+ if (r.contextTokens !== undefined && r.contextWindow !== undefined && r.contextWindow > 0) {
16402
+ currentSessions.set(r.topicId, {
16403
+ timestamp: r.timestamp,
16404
+ topicId: r.topicId,
16405
+ topicTitle: r.session,
16406
+ ...r.providerSessionId ? { providerSessionId: r.providerSessionId } : {},
16407
+ agent: r.agent,
16408
+ model: r.model,
16409
+ contextTokens: r.contextTokens,
16410
+ contextWindow: r.contextWindow
16411
+ });
16412
+ }
16413
+ }
16414
+ return {
16415
+ total,
16416
+ byHour,
16417
+ bySession,
16418
+ currentSessions: [...currentSessions.values()].sort((a, b) => b.timestamp.localeCompare(a.timestamp)),
16419
+ ignoredLegacyRecords,
16420
+ estimatedCostUsd: calcCost(total)
16421
+ };
16422
+ }
16423
+ function getTopicStats(userId, topicId, activeProviderSessionId = getTopicSessionId(topicId) ?? undefined) {
16424
+ const total = emptyBucket();
16425
+ let currentSession;
16426
+ for (const raw of loadRecords(userId)) {
16427
+ if (!isQueryRecord(raw) || raw.topicId !== topicId)
16428
+ continue;
16429
+ total.inputTokens += raw.inputTokens;
16430
+ total.outputTokens += raw.outputTokens;
16431
+ total.cacheCreationInputTokens += raw.cacheCreationInputTokens;
16432
+ total.cacheReadInputTokens += raw.cacheReadInputTokens;
16433
+ total.queries += 1;
16434
+ total.estimatedCostUsd += raw.estimatedCostUsd;
16435
+ if (raw.contextTokens !== undefined && raw.contextWindow !== undefined && raw.contextWindow > 0 && activeProviderSessionId !== undefined && raw.providerSessionId === activeProviderSessionId && (!currentSession || raw.timestamp > currentSession.timestamp)) {
16436
+ currentSession = {
16437
+ timestamp: raw.timestamp,
16438
+ topicId: raw.topicId,
16439
+ topicTitle: raw.session,
16440
+ ...raw.providerSessionId ? { providerSessionId: raw.providerSessionId } : {},
16441
+ agent: raw.agent,
16442
+ model: raw.model,
16443
+ contextTokens: raw.contextTokens,
16444
+ contextWindow: raw.contextWindow
16445
+ };
16446
+ }
16447
+ }
16448
+ return { topicId, ...total, ...currentSession ? { currentSession } : {} };
16449
+ }
16450
+ var TOKEN_PRICES;
16451
+ var init_token_stats = __esm(async () => {
16452
+ init_jsonl();
16453
+ init_logger();
16454
+ await init_api_topics();
16455
+ await init_storage_host();
16456
+ TOKEN_PRICES = {
16457
+ "codex:gpt-5.6-sol": { input: 5, cacheRead: 0.5, output: 30 },
16458
+ "codex:gpt-5.6-terra": { input: 2.5, cacheRead: 0.25, output: 15 },
16459
+ "codex:gpt-5.6-luna": { input: 1, cacheRead: 0.1, output: 6 },
16460
+ "claude:fable": { input: 10, cacheWrite: 12.5, cacheRead: 1, output: 50 },
16461
+ "claude:opus": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
16462
+ "claude:sonnet": { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 },
16463
+ "maestro:kimi-k3": { input: 3, cacheRead: 0.3, output: 15 },
16464
+ "maestro:kimi-k2.7-code": { input: 0.95, cacheRead: 0.19, output: 4 },
16465
+ "maestro:deepseek-pro": { input: 0.435, cacheRead: 0.003625, output: 0.87 },
16466
+ "maestro:deepseek-flash": { input: 0.14, cacheRead: 0.0028, output: 0.28 }
16467
+ };
16468
+ });
16469
+
16156
16470
  // ../../packages/core/src/topics/lifecycle.ts
16157
16471
  var exports_lifecycle = {};
16158
16472
  __export(exports_lifecycle, {
@@ -16202,6 +16516,7 @@ async function cleanupParticipantResources(topic, userIds, sessionId, cwd, purge
16202
16516
  cleanupSessionInboxFiles(participantUserId, topic.id, topic.title);
16203
16517
  clearQueryState(participantUserId, topic.id, topic.title);
16204
16518
  clearQueryUsageAlert(participantUserId, topic.id);
16519
+ deleteTopicStats(participantUserId, topic.id);
16205
16520
  deletePendingAsksForTopic({ userId: participantUserId, topicName: topic.title });
16206
16521
  }
16207
16522
  return true;
@@ -16356,6 +16671,7 @@ var init_lifecycle = __esm(async () => {
16356
16671
  await init_runtime_turn_requests();
16357
16672
  await init_self_schedules();
16358
16673
  await init_session_asks();
16674
+ await init_token_stats();
16359
16675
  await init_topic_archive();
16360
16676
  await init_topic_archive_state();
16361
16677
  TopicArchiveRequiredError = class TopicArchiveRequiredError extends Error {
@@ -16390,8 +16706,8 @@ var init_lifecycle = __esm(async () => {
16390
16706
 
16391
16707
  // ../../packages/core/src/runtime/attachments.ts
16392
16708
  import { randomUUID as randomUUID14 } from "crypto";
16393
- import { copyFileSync as copyFileSync2, mkdirSync as mkdirSync16, writeFileSync as writeFileSync14 } from "fs";
16394
- import { basename as basename5, join as join25 } from "path";
16709
+ import { copyFileSync as copyFileSync2, mkdirSync as mkdirSync17, writeFileSync as writeFileSync15 } from "fs";
16710
+ import { basename as basename5, join as join26 } from "path";
16395
16711
  function workspaceCwdFor(topicId) {
16396
16712
  return resolveTopicWorkspaceDir(topicId);
16397
16713
  }
@@ -16404,7 +16720,7 @@ function materializePromptAttachments(topicId, queryId, attachmentIds) {
16404
16720
  if (!attachmentIds?.length)
16405
16721
  return [];
16406
16722
  const out = [];
16407
- const destDir = join25(workspaceCwdFor(topicId), "attachments", queryId);
16723
+ const destDir = join26(workspaceCwdFor(topicId), "attachments", queryId);
16408
16724
  for (const rawId of attachmentIds) {
16409
16725
  if (typeof rawId !== "string")
16410
16726
  continue;
@@ -16418,10 +16734,10 @@ function materializePromptAttachments(topicId, queryId, attachmentIds) {
16418
16734
  continue;
16419
16735
  }
16420
16736
  try {
16421
- mkdirSync16(destDir, { recursive: true });
16737
+ mkdirSync17(destDir, { recursive: true });
16422
16738
  const index = String(out.length + 1).padStart(2, "0");
16423
16739
  const safeName = safeAttachmentFilename(attachment.filename, fileId);
16424
- const destPath = join25(destDir, `${index}-${fileId.slice(0, 8)}-${safeName}`);
16740
+ const destPath = join26(destDir, `${index}-${fileId.slice(0, 8)}-${safeName}`);
16425
16741
  copyFileSync2(sourcePath, destPath);
16426
16742
  out.push({
16427
16743
  id: attachment.id,
@@ -16451,14 +16767,14 @@ function promptWithAttachments(prompt, attachments) {
16451
16767
  return composeAttachmentPrompt(prompt, attachments.map(({ filename, path }) => attachmentPromptLine(filename, path)));
16452
16768
  }
16453
16769
  function ingestAttachment(args) {
16454
- const destDir = join25(UPLOADS_DIR, args.topicId);
16455
- mkdirSync16(destDir, { recursive: true });
16770
+ const destDir = join26(UPLOADS_DIR, args.topicId);
16771
+ mkdirSync17(destDir, { recursive: true });
16456
16772
  const safeName = safeAttachmentFilename(args.filename, "upload");
16457
- const destPath = join25(destDir, `${Date.now()}-${randomUUID14().slice(0, 8)}-${safeName}`);
16773
+ const destPath = join26(destDir, `${Date.now()}-${randomUUID14().slice(0, 8)}-${safeName}`);
16458
16774
  if (args.sourcePath !== undefined) {
16459
16775
  copyFileSync2(args.sourcePath, destPath);
16460
16776
  } else if (args.bytes !== undefined) {
16461
- writeFileSync14(destPath, args.bytes);
16777
+ writeFileSync15(destPath, args.bytes);
16462
16778
  } else {
16463
16779
  throw new Error("ingestAttachment: provide sourcePath or bytes");
16464
16780
  }
@@ -17054,204 +17370,6 @@ var init_visuals = __esm(async () => {
17054
17370
  init_visual_html();
17055
17371
  });
17056
17372
 
17057
- // ../../packages/core/src/storage/token-stats.ts
17058
- var exports_token_stats = {};
17059
- __export(exports_token_stats, {
17060
- tokenStatsFileId: () => tokenStatsFileId,
17061
- recordUsage: () => recordUsage,
17062
- getTopicStats: () => getTopicStats,
17063
- getStats: () => getStats,
17064
- calcCost: () => calcCost
17065
- });
17066
- import { createHash as createHash7 } from "crypto";
17067
- import { mkdirSync as mkdirSync17 } from "fs";
17068
- import { join as join26 } from "path";
17069
- function emptyBucket() {
17070
- return {
17071
- inputTokens: 0,
17072
- outputTokens: 0,
17073
- cacheCreationInputTokens: 0,
17074
- cacheReadInputTokens: 0,
17075
- queries: 0,
17076
- estimatedCostUsd: 0
17077
- };
17078
- }
17079
- function tokenStatsFileId(userId) {
17080
- const rawUserId = String(userId);
17081
- return /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,255}$/.test(rawUserId) && !rawUserId.includes("..") ? rawUserId : `sha256-${createHash7("sha256").update(rawUserId).digest("hex")}`;
17082
- }
17083
- function queriesPath(userId) {
17084
- const fileId = tokenStatsFileId(userId);
17085
- const logDir = resolveStorageLogDir();
17086
- mkdirSync17(logDir, { recursive: true });
17087
- return join26(logDir, `token-queries-${fileId}.jsonl`);
17088
- }
17089
- function loadRecords(userId) {
17090
- try {
17091
- return readJsonlLines(queriesPath(userId)).flatMap((line) => {
17092
- try {
17093
- return [JSON.parse(line)];
17094
- } catch {
17095
- return [];
17096
- }
17097
- });
17098
- } catch {
17099
- return [];
17100
- }
17101
- }
17102
- function calcCost(b) {
17103
- return b.estimatedCostUsd;
17104
- }
17105
- function estimateUsageCost(agent, model, usage) {
17106
- const prices = TOKEN_PRICES[`${agent}:${model}`];
17107
- if (!prices)
17108
- return 0;
17109
- return (usage.inputTokens * prices.input + usage.outputTokens * prices.output + (agent === "claude" ? usage.cacheCreationInputTokens * (prices.cacheWrite ?? prices.input) : 0) + usage.cacheReadInputTokens * prices.cacheRead) / 1e6;
17110
- }
17111
- function isQueryRecord(value) {
17112
- if (!value || typeof value !== "object")
17113
- return false;
17114
- const record = value;
17115
- return record.schemaVersion === 2 && typeof record.timestamp === "string" && typeof record.session === "string" && typeof record.topicId === "string" && typeof record.agent === "string" && typeof record.model === "string" && typeof record.inputTokens === "number" && typeof record.outputTokens === "number" && typeof record.cacheCreationInputTokens === "number" && typeof record.cacheReadInputTokens === "number" && typeof record.estimatedCostUsd === "number";
17116
- }
17117
- function recordUsage(userId, session, usage, context) {
17118
- const cacheReadInputTokens = usage.cacheReadInputTokens ?? 0;
17119
- const inputTokens = context.agent === "claude" ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheReadInputTokens);
17120
- const normalized = {
17121
- inputTokens,
17122
- outputTokens: usage.outputTokens,
17123
- cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
17124
- cacheReadInputTokens
17125
- };
17126
- const record = {
17127
- schemaVersion: 2,
17128
- timestamp: new Date().toISOString(),
17129
- session,
17130
- topicId: context.topicId,
17131
- ...context.providerSessionId ? { providerSessionId: context.providerSessionId } : {},
17132
- agent: context.agent,
17133
- model: context.model,
17134
- ...normalized,
17135
- ...usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {},
17136
- ...usage.contextWindow !== undefined ? { contextWindow: usage.contextWindow } : {},
17137
- estimatedCostUsd: usage.costUsd ?? estimateUsageCost(context.agent, context.model, normalized)
17138
- };
17139
- try {
17140
- appendJsonlEntry(queriesPath(userId), record);
17141
- } catch (e) {
17142
- logger.warn({ err: e, userId }, "token-stats: Failed to record");
17143
- }
17144
- }
17145
- function getStats(userId, from, to) {
17146
- const records = loadRecords(userId);
17147
- const fromTs = from ? new Date(from).getTime() : 0;
17148
- const toTs = to ? new Date(to).getTime() : Infinity;
17149
- if (from && Number.isNaN(fromTs) || to && Number.isNaN(toTs)) {
17150
- logger.warn({ from, to }, "token-stats: Invalid date range, returning empty");
17151
- return {
17152
- total: emptyBucket(),
17153
- byHour: {},
17154
- bySession: {},
17155
- currentSessions: [],
17156
- ignoredLegacyRecords: 0,
17157
- estimatedCostUsd: 0
17158
- };
17159
- }
17160
- const total = emptyBucket();
17161
- const byHour = {};
17162
- const bySession = {};
17163
- const currentSessions = new Map;
17164
- let ignoredLegacyRecords = 0;
17165
- for (const raw of records) {
17166
- if (!isQueryRecord(raw)) {
17167
- ignoredLegacyRecords += 1;
17168
- continue;
17169
- }
17170
- const r = raw;
17171
- const ts = new Date(r.timestamp).getTime();
17172
- if (ts < fromTs || ts > toTs)
17173
- continue;
17174
- const hourKey = r.timestamp.slice(0, 13);
17175
- if (!byHour[hourKey])
17176
- byHour[hourKey] = emptyBucket();
17177
- if (!bySession[r.session])
17178
- bySession[r.session] = emptyBucket();
17179
- for (const bucket of [total, byHour[hourKey], bySession[r.session]]) {
17180
- bucket.inputTokens += r.inputTokens;
17181
- bucket.outputTokens += r.outputTokens;
17182
- bucket.cacheCreationInputTokens += r.cacheCreationInputTokens;
17183
- bucket.cacheReadInputTokens += r.cacheReadInputTokens;
17184
- bucket.queries += 1;
17185
- bucket.estimatedCostUsd += r.estimatedCostUsd;
17186
- }
17187
- if (r.contextTokens !== undefined && r.contextWindow !== undefined && r.contextWindow > 0) {
17188
- currentSessions.set(r.topicId, {
17189
- timestamp: r.timestamp,
17190
- topicId: r.topicId,
17191
- topicTitle: r.session,
17192
- ...r.providerSessionId ? { providerSessionId: r.providerSessionId } : {},
17193
- agent: r.agent,
17194
- model: r.model,
17195
- contextTokens: r.contextTokens,
17196
- contextWindow: r.contextWindow
17197
- });
17198
- }
17199
- }
17200
- return {
17201
- total,
17202
- byHour,
17203
- bySession,
17204
- currentSessions: [...currentSessions.values()].sort((a, b) => b.timestamp.localeCompare(a.timestamp)),
17205
- ignoredLegacyRecords,
17206
- estimatedCostUsd: calcCost(total)
17207
- };
17208
- }
17209
- function getTopicStats(userId, topicId) {
17210
- const total = emptyBucket();
17211
- let currentSession;
17212
- for (const raw of loadRecords(userId)) {
17213
- if (!isQueryRecord(raw) || raw.topicId !== topicId)
17214
- continue;
17215
- total.inputTokens += raw.inputTokens;
17216
- total.outputTokens += raw.outputTokens;
17217
- total.cacheCreationInputTokens += raw.cacheCreationInputTokens;
17218
- total.cacheReadInputTokens += raw.cacheReadInputTokens;
17219
- total.queries += 1;
17220
- total.estimatedCostUsd += raw.estimatedCostUsd;
17221
- if (raw.contextTokens !== undefined && raw.contextWindow !== undefined && raw.contextWindow > 0 && (!currentSession || raw.timestamp > currentSession.timestamp)) {
17222
- currentSession = {
17223
- timestamp: raw.timestamp,
17224
- topicId: raw.topicId,
17225
- topicTitle: raw.session,
17226
- ...raw.providerSessionId ? { providerSessionId: raw.providerSessionId } : {},
17227
- agent: raw.agent,
17228
- model: raw.model,
17229
- contextTokens: raw.contextTokens,
17230
- contextWindow: raw.contextWindow
17231
- };
17232
- }
17233
- }
17234
- return { topicId, ...total, ...currentSession ? { currentSession } : {} };
17235
- }
17236
- var TOKEN_PRICES;
17237
- var init_token_stats = __esm(async () => {
17238
- init_jsonl();
17239
- init_logger();
17240
- await init_storage_host();
17241
- TOKEN_PRICES = {
17242
- "codex:gpt-5.6-sol": { input: 5, cacheRead: 0.5, output: 30 },
17243
- "codex:gpt-5.6-terra": { input: 2.5, cacheRead: 0.25, output: 15 },
17244
- "codex:gpt-5.6-luna": { input: 1, cacheRead: 0.1, output: 6 },
17245
- "claude:fable": { input: 10, cacheWrite: 12.5, cacheRead: 1, output: 50 },
17246
- "claude:opus": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
17247
- "claude:sonnet": { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 },
17248
- "maestro:kimi-k3": { input: 3, cacheRead: 0.3, output: 15 },
17249
- "maestro:kimi-k2.7-code": { input: 0.95, cacheRead: 0.19, output: 4 },
17250
- "maestro:deepseek-pro": { input: 0.435, cacheRead: 0.003625, output: 0.87 },
17251
- "maestro:deepseek-flash": { input: 0.14, cacheRead: 0.0028, output: 0.28 }
17252
- };
17253
- });
17254
-
17255
17373
  // ../../packages/core/src/runtime/turn-event-stream.ts
17256
17374
  import { randomUUID as randomUUID15 } from "crypto";
17257
17375
  import { realpathSync as realpathSync5, statSync as statSync9 } from "fs";
@@ -17367,10 +17485,11 @@ async function runTurnEventStream(topicId, topicTitle, queryId, events, control,
17367
17485
  agentType,
17368
17486
  model,
17369
17487
  sourceNode: execution?.sourceNode,
17488
+ ...execution?.threadRootId ? { threadRootId: execution.threadRootId } : {},
17370
17489
  usage,
17371
17490
  createdAt: new Date().toISOString()
17372
17491
  };
17373
- appendApiMessage(message);
17492
+ appendApiMessage(message, execution?.threadRootId ? { updateTopicLastMessageAt: false } : undefined);
17374
17493
  hub.broadcastMessage(topicId, message);
17375
17494
  lastVisibleMessageId = message.id;
17376
17495
  visibleMessageIds.push(message.id);
@@ -17892,7 +18011,7 @@ __export(exports_app_settings, {
17892
18011
  getGlobalAiName: () => getGlobalAiName,
17893
18012
  DEFAULT_AI_NAME: () => DEFAULT_AI_NAME
17894
18013
  });
17895
- import { existsSync as existsSync18, mkdirSync as mkdirSync18, readFileSync as readFileSync16, writeFileSync as writeFileSync15 } from "fs";
18014
+ import { existsSync as existsSync18, mkdirSync as mkdirSync18, readFileSync as readFileSync16, writeFileSync as writeFileSync16 } from "fs";
17896
18015
  import { dirname as dirname14, join as join27 } from "path";
17897
18016
  function settingsFile() {
17898
18017
  return join27(resolveStorageDataDir(), "otium-settings.json");
@@ -17922,7 +18041,7 @@ function setGlobalAiName(name) {
17922
18041
  aiName = name.trim() || DEFAULT_AI_NAME;
17923
18042
  try {
17924
18043
  mkdirSync18(dirname14(path), { recursive: true });
17925
- writeFileSync15(path, JSON.stringify({ aiName }, null, 2));
18044
+ writeFileSync16(path, JSON.stringify({ aiName }, null, 2));
17926
18045
  } catch {}
17927
18046
  return aiName;
17928
18047
  }
@@ -18419,6 +18538,7 @@ async function drainOneDurableUserTurn() {
18419
18538
  bridgeSessionFromHistory: execution?.bridgeSessionFromHistory,
18420
18539
  peerBridge: execution?.peerBridge,
18421
18540
  from: execution?.from,
18541
+ threadRootId: execution?.threadRootId,
18422
18542
  _queryId: request.requestId,
18423
18543
  _runtimeEpoch: execution?.runtimeEpoch ?? request.topicEpoch,
18424
18544
  onSettled: () => {
@@ -18491,6 +18611,7 @@ function startAiTurn(params) {
18491
18611
  let sessionId = sessionResolution.sessionId;
18492
18612
  const deferredSessionId = params.sessionId === undefined && !sessionResolution.isolated ? undefined : sessionId;
18493
18613
  const sourceNode = params.sourceNode;
18614
+ const threadRootId = params.threadRootId;
18494
18615
  const topicId = topic.id;
18495
18616
  const requestId = params.requestId;
18496
18617
  const depth = params.depth;
@@ -19019,7 +19140,7 @@ function startAiTurn(params) {
19019
19140
  WsHub.get().broadcastTyping(topicId, "ai");
19020
19141
  WsHub.get().broadcastAiActive(topicId, queryId);
19021
19142
  }
19022
- streamAgentEvents(topicId, topic.title, queryId, events, control, agentKind, resolvedModel, resolvedEffort, userId, !sessionRetried, onSessionId, { silent, peerBridge, sourceNode }).then(async (streamOutcome) => {
19143
+ streamAgentEvents(topicId, topic.title, queryId, events, control, agentKind, resolvedModel, resolvedEffort, userId, !sessionRetried, onSessionId, { silent, peerBridge, sourceNode, ...threadRootId ? { threadRootId } : {} }).then(async (streamOutcome) => {
19023
19144
  let outcome = streamOutcome;
19024
19145
  if (outcome.kind === "session-expired") {
19025
19146
  const retry = resolveSessionRetry({
@@ -20279,7 +20400,8 @@ function gatewayPayloadHash(params, requestId, actorUserId) {
20279
20400
  params.text,
20280
20401
  params.clientMessageId,
20281
20402
  requestId,
20282
- params.allowAutoContinue ?? true
20403
+ params.allowAutoContinue ?? true,
20404
+ params.threadRootId ?? null
20283
20405
  ])).digest("hex");
20284
20406
  }
20285
20407
  function submitRuntimeGatewayTurn(params) {
@@ -20299,6 +20421,7 @@ function submitRuntimeGatewayTurn(params) {
20299
20421
  sourceAdapter: "runtime-gateway",
20300
20422
  sourceMessageId: params.clientMessageId,
20301
20423
  text: params.text,
20424
+ ...params.threadRootId ? { threadRootId: params.threadRootId } : {},
20302
20425
  createdAt
20303
20426
  };
20304
20427
  const submission = {
@@ -20333,7 +20456,8 @@ function submitRuntimeGatewayTurn(params) {
20333
20456
  sessionIdSpecified: true,
20334
20457
  conversationPrompts: [params.text],
20335
20458
  loggedUserMessageCount: 0,
20336
- vaultUserId: params.vaultUserId
20459
+ vaultUserId: params.vaultUserId,
20460
+ ...params.threadRootId ? { threadRootId: params.threadRootId } : {}
20337
20461
  }
20338
20462
  });
20339
20463
  const acceptedEvent = appendRuntimeEvent("runtime-gateway-ingress", {
@@ -20526,7 +20650,11 @@ function registerTopic(opts) {
20526
20650
  throw new TopicValidationError("Manager rooms are system-managed");
20527
20651
  }
20528
20652
  const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
20529
- const conflict = findTopicTitleConflict(title, requestedKind, { surface });
20653
+ const surfaceScope = opts.surfaceScope !== undefined ? normalizeSurfaceScope(opts.surfaceScope) : surface === "otium" ? defaultSurfaceScope() : null;
20654
+ if (surface === "otium" && !surfaceScope && isSurfaceScopeRequired()) {
20655
+ throw new TopicValidationError("this node serves several Otium workspaces; a room must name one to be created");
20656
+ }
20657
+ const conflict = findTopicTitleConflict(title, requestedKind, { surface, surfaceScope });
20530
20658
  if (conflict) {
20531
20659
  throw new TopicValidationError(`A topic named "${title}" already exists on ${surface}`);
20532
20660
  }
@@ -20563,6 +20691,7 @@ function registerTopic(opts) {
20563
20691
  aiMode,
20564
20692
  participants: [{ userId: opts.userId, role: "owner" }],
20565
20693
  surface,
20694
+ surfaceScope,
20566
20695
  createdAt: now,
20567
20696
  lastMessageAt: now
20568
20697
  };
@@ -21716,7 +21845,7 @@ var init_file_ops = __esm(() => {
21716
21845
 
21717
21846
  // ../../packages/core/src/runtime/inbox.ts
21718
21847
  import { createHash as createHash9, randomUUID as randomUUID20 } from "crypto";
21719
- import { readdirSync as readdirSync8, statSync as statSync13, writeFileSync as writeFileSync16 } from "fs";
21848
+ import { readdirSync as readdirSync8, statSync as statSync13, writeFileSync as writeFileSync17 } from "fs";
21720
21849
  import { join as join32 } from "path";
21721
21850
  async function createAskForkPlan(options) {
21722
21851
  const snapshot = structuredClone(options.entries);
@@ -22006,7 +22135,7 @@ function sweepScheduledSessionInbox(nowMs = Date.now()) {
22006
22135
  total: due.length + pending.length
22007
22136
  }, "self-schedule: promotion interrupted; retaining only the unpromoted entries");
22008
22137
  try {
22009
- writeFileSync16(drained.processingPath, `${unfinished.map((entry) => JSON.stringify(entry)).join(`
22138
+ writeFileSync17(drained.processingPath, `${unfinished.map((entry) => JSON.stringify(entry)).join(`
22010
22139
  `)}
22011
22140
  `);
22012
22141
  } catch (rewriteErr) {
@@ -22413,13 +22542,16 @@ __export(exports_src, {
22413
22542
  startBashrsCompletionsWorker: () => startBashrsCompletionsWorker,
22414
22543
  startAskUserQuestionGateOwner: () => startAskUserQuestionGateOwner,
22415
22544
  startAiTurn: () => startAiTurn,
22545
+ stampUnscopedOtiumTopics: () => stampUnscopedOtiumTopics,
22416
22546
  setTopicSurfaces: () => setTopicSurfaces,
22417
22547
  setTopicSessionId: () => setTopicSessionId,
22548
+ setSurfaceScopeRequired: () => setSurfaceScopeRequired,
22418
22549
  setRuntimeMcpPort: () => setRuntimeMcpPort,
22419
22550
  setRuntimeBus: () => setRuntimeBus,
22420
22551
  setPlaywrightUnavailableNotifier: () => setPlaywrightUnavailableNotifier,
22421
22552
  setNodeMcpServers: () => setNodeMcpServers,
22422
22553
  setFileHooks: () => setFileHooks,
22554
+ setDefaultSurfaceScope: () => setDefaultSurfaceScope,
22423
22555
  setBashrsCompletionSink: () => setBashrsCompletionSink,
22424
22556
  setApiTopicConfig: () => setApiTopicConfig,
22425
22557
  sessionInboxPath: () => sessionInboxPath,
@@ -22461,6 +22593,7 @@ __export(exports_src, {
22461
22593
  onShutdown: () => onShutdown,
22462
22594
  normalizeVaultKey: () => normalizeVaultKey,
22463
22595
  normalizeTopicSurface: () => normalizeTopicSurface,
22596
+ normalizeSurfaceScope: () => normalizeSurfaceScope,
22464
22597
  nodeRequestHandlerNames: () => nodeRequestHandlerNames,
22465
22598
  modelOwner: () => modelOwner,
22466
22599
  markPlaywrightUnavailable: () => markPlaywrightUnavailable,
@@ -22488,6 +22621,7 @@ __export(exports_src, {
22488
22621
  isTranscriptionConfigured: () => isTranscriptionConfigured,
22489
22622
  isTopicVisible: () => isTopicVisible,
22490
22623
  isTopicRunning: () => isTopicRunning,
22624
+ isSurfaceScopeRequired: () => isSurfaceScopeRequired,
22491
22625
  isSensitivePath: () => isSensitivePath,
22492
22626
  isParticipant: () => isParticipant,
22493
22627
  isHostedMcpSurface: () => isHostedMcpSurface,
@@ -22537,6 +22671,7 @@ __export(exports_src, {
22537
22671
  deleteVaultEntry: () => deleteVaultEntry,
22538
22672
  deleteTopicCascade: () => deleteTopicCascade,
22539
22673
  defaultTopicSurface: () => defaultTopicSurface,
22674
+ defaultSurfaceScope: () => defaultSurfaceScope,
22540
22675
  db: () => db,
22541
22676
  createSubagentManagementToolDefinitions: () => createSubagentManagementToolDefinitions,
22542
22677
  createSpawnSubagentToolDefinition: () => createSpawnSubagentToolDefinition,
@@ -22834,6 +22969,7 @@ __export(exports_node_host, {
22834
22969
  getVisibleTopics: () => getVisibleTopics,
22835
22970
  getTopicStats: () => getTopicStats,
22836
22971
  getTopic: () => getTopic,
22972
+ getApiMessage: () => getApiMessage,
22837
22973
  flushBashrsCompletions: () => flushBashrsCompletions,
22838
22974
  executeVaultCommand: () => executeVaultCommand,
22839
22975
  ensurePersonalGeneral: () => ensurePersonalGeneral,
@@ -23168,6 +23304,7 @@ __export(exports_mcp_runtime_host, {
23168
23304
  dispatchPeerRuntimeFile: () => dispatchPeerRuntimeFile,
23169
23305
  dispatchPeerRuntimeAskUser: () => dispatchPeerRuntimeAskUser,
23170
23306
  deleteTopicCascade: () => deleteTopicCascade,
23307
+ defaultTopicSurface: () => defaultTopicSurface,
23171
23308
  createSubagentManagementToolDefinitions: () => createSubagentManagementToolDefinitions,
23172
23309
  createSpawnSubagentToolDefinition: () => createSpawnSubagentToolDefinition,
23173
23310
  createSelfConfigToolDefinitions: () => createSelfConfigToolDefinitions,
@@ -23224,6 +23361,9 @@ var init_mcp_runtime_host = __esm(async () => {
23224
23361
 
23225
23362
  // ../../packages/mcp/src/node-tools.ts
23226
23363
  import { z as z6 } from "zod";
23364
+ function callerSurface(ctx) {
23365
+ return getTopic(ctx.topicId)?.surface ?? defaultTopicSurface();
23366
+ }
23227
23367
  function resolveTopicForUser(ctx, ref) {
23228
23368
  const trimmed = ref.trim();
23229
23369
  if (!trimmed)
@@ -23235,7 +23375,7 @@ function resolveTopicForUser(ctx, ref) {
23235
23375
  return { error: notFound };
23236
23376
  return { topic: byId };
23237
23377
  }
23238
- const byTitle = getTopicByNameForUser(trimmed, ctx.userId);
23378
+ const byTitle = getTopicByNameForUser(trimmed, ctx.userId, { surface: callerSurface(ctx) });
23239
23379
  if (byTitle)
23240
23380
  return { topic: byTitle };
23241
23381
  return { error: notFound };
@@ -23257,6 +23397,7 @@ function registerNodeTools(server, ctx) {
23257
23397
  const topic = registerTopic({
23258
23398
  title,
23259
23399
  userId: ctx.userId,
23400
+ surface: callerSurface(ctx),
23260
23401
  agent,
23261
23402
  model,
23262
23403
  effort,
@@ -23278,7 +23419,7 @@ function registerNodeTools(server, ctx) {
23278
23419
  }
23279
23420
  });
23280
23421
  server.tool("list_topics", "List the calling user's topics on this negotium node: title, id, kind, agent, and whether a turn is currently running.", {}, async () => {
23281
- const topics = getTopics().filter((topic) => isParticipant(topic, ctx.userId));
23422
+ const topics = getTopics({ surface: callerSurface(ctx) }).filter((topic) => isParticipant(topic, ctx.userId));
23282
23423
  if (topics.length === 0) {
23283
23424
  return textResult("No topics found. Use register_topic to create one.");
23284
23425
  }
@@ -24255,13 +24396,15 @@ function currentTopic(context) {
24255
24396
  return topic;
24256
24397
  }
24257
24398
  function targetCatalog(context) {
24258
- const surface = (context.currentTopicId ? getTopic(context.currentTopicId)?.surface : undefined) ?? defaultTopicSurface();
24399
+ const current3 = context.currentTopicId ? getTopic(context.currentTopicId) : null;
24400
+ const surface = current3?.surface ?? defaultTopicSurface();
24401
+ const surfaceScope = current3 ? current3.surfaceScope ?? null : surface === "otium" ? defaultSurfaceScope() : null;
24259
24402
  return createSessionTargetCatalog({
24260
24403
  currentTopicId: context.currentTopicId,
24261
24404
  currentTopicName: context.currentTopic,
24262
24405
  currentSurface: surface,
24263
24406
  isAgent: isAgentKind,
24264
- listRows: () => listTopics().filter((topic) => topic.participants.some((p) => p.userId === context.userId)).map((topic) => ({
24407
+ listRows: () => listTopics({ surface, surfaceScope }).filter((topic) => topic.participants.some((p) => p.userId === context.userId)).map((topic) => ({
24265
24408
  id: topic.id,
24266
24409
  title: topic.title,
24267
24410
  kind: topic.kind ?? null,
@@ -24313,7 +24456,7 @@ function createDefaultSessionCommMcpHost() {
24313
24456
  const entries = targetCatalog(context).listTargets().filter(({ topic }) => !identity.restricted || Boolean(topic.topicId && canSubagentTellTarget(identity, topic.topicId))).filter(({ topic }) => Boolean(topic.agent)).map(({ key, topic }) => `- ${key}: ${topic.sessionId ? "active" : "fresh-start ready"}${topic.description ? `
24314
24457
  description: ${topic.description.slice(0, 80)}` : ""}`);
24315
24458
  if (!identity.restricted) {
24316
- const peers = await peerSessionsForUser(context.userId, context.peerHostQueryId);
24459
+ const peers = await peerSessionsForUser(context.userId, context.peerHostQueryId, context.currentTopicId);
24317
24460
  for (const node of peers.nodes ?? []) {
24318
24461
  for (const session of node.sessions ?? []) {
24319
24462
  if (session.agent)
@@ -25158,7 +25301,7 @@ import {
25158
25301
  renameSync as renameSync11,
25159
25302
  statSync as statSync15,
25160
25303
  unlinkSync as unlinkSync19,
25161
- writeFileSync as writeFileSync17
25304
+ writeFileSync as writeFileSync18
25162
25305
  } from "fs";
25163
25306
  import { basename as basename7, dirname as dirname16, join as join36, relative as relative2, resolve as resolve17 } from "path";
25164
25307
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -25196,7 +25339,7 @@ function acquireRecoveryGuard(lockPath) {
25196
25339
  try {
25197
25340
  const fd = openSync4(guardPath, "wx");
25198
25341
  try {
25199
- writeFileSync17(fd, ownerToken, "utf-8");
25342
+ writeFileSync18(fd, ownerToken, "utf-8");
25200
25343
  } catch (error2) {
25201
25344
  closeSync4(fd);
25202
25345
  try {
@@ -25229,7 +25372,7 @@ function acquireFileLock(path) {
25229
25372
  try {
25230
25373
  const fd = openSync4(lockPath, "wx");
25231
25374
  try {
25232
- writeFileSync17(fd, ownerToken, "utf-8");
25375
+ writeFileSync18(fd, ownerToken, "utf-8");
25233
25376
  } catch (error2) {
25234
25377
  closeSync4(fd);
25235
25378
  try {
@@ -25273,7 +25416,7 @@ function acquireFileLock(path) {
25273
25416
  }
25274
25417
  function atomicWriteFile(path, content) {
25275
25418
  const temporaryPath = `${path}.tmp.${process.pid}.${Date.now()}`;
25276
- writeFileSync17(temporaryPath, content, "utf-8");
25419
+ writeFileSync18(temporaryPath, content, "utf-8");
25277
25420
  try {
25278
25421
  renameSync11(temporaryPath, path);
25279
25422
  } catch (error2) {
@@ -26153,7 +26296,7 @@ ${fresh.join(`
26153
26296
  const existing = readFileSync23(skillPath, "utf-8");
26154
26297
  finalContent = mergeGotchas(content, extractGotchas(existing));
26155
26298
  } catch {}
26156
- writeFileSync17(skillPath, finalContent, "utf-8");
26299
+ writeFileSync18(skillPath, finalContent, "utf-8");
26157
26300
  return {
26158
26301
  content: [
26159
26302
  {
@@ -26172,7 +26315,7 @@ function writeSummaryDocument(rawTopic, content, dateStr) {
26172
26315
  let counter = 1;
26173
26316
  while (true) {
26174
26317
  try {
26175
- writeFileSync17(resolve17(runtime().summariesDir, summaryName), content, {
26318
+ writeFileSync18(resolve17(runtime().summariesDir, summaryName), content, {
26176
26319
  encoding: "utf-8",
26177
26320
  flag: "wx"
26178
26321
  });
@@ -26212,7 +26355,7 @@ function writeTopicDocument(rawTopic, content) {
26212
26355
  const briefPath = resolve17(runtime().topicsDir, `${fileSlug}.md`);
26213
26356
  ensureDir(dirname16(briefPath));
26214
26357
  const existed = existsSync27(briefPath);
26215
- writeFileSync17(briefPath, content, "utf-8");
26358
+ writeFileSync18(briefPath, content, "utf-8");
26216
26359
  const setTopicBrief2 = runtime().host.setTopicBrief;
26217
26360
  let sqliteUpdated = false;
26218
26361
  if (topicId && setTopicBrief2) {
@@ -26879,7 +27022,7 @@ import {
26879
27022
  renameSync as renameSync12,
26880
27023
  statSync as statSync16,
26881
27024
  unlinkSync as unlinkSync20,
26882
- writeFileSync as writeFileSync18
27025
+ writeFileSync as writeFileSync19
26883
27026
  } from "fs";
26884
27027
  import { basename as basename8, join as join37 } from "path";
26885
27028
  function startLogRotation() {
@@ -26979,7 +27122,7 @@ function removeSentFilesForTopic(userId, topicName) {
26979
27122
  }
26980
27123
  });
26981
27124
  if (remaining.length > 0) {
26982
- writeFileSync18(file, `${remaining.join(`
27125
+ writeFileSync19(file, `${remaining.join(`
26983
27126
  `)}
26984
27127
  `);
26985
27128
  } else {
@@ -27501,6 +27644,7 @@ __export(exports_storage_public, {
27501
27644
  tasks: () => exports_tasks,
27502
27645
  taskScopeKey: () => taskScopeKey,
27503
27646
  taskFileMtimeNs: () => taskFileMtimeNs,
27647
+ stampUnscopedOtiumTopics: () => stampUnscopedOtiumTopics,
27504
27648
  softDeleteApiMessagesByIdPrefix: () => softDeleteApiMessagesByIdPrefix,
27505
27649
  softDeleteApiMessage: () => softDeleteApiMessage,
27506
27650
  settleTopicArchiveJob: () => settleTopicArchiveJob,
@@ -27515,10 +27659,12 @@ __export(exports_storage_public, {
27515
27659
  setTopicAgentAndSession: () => setTopicAgentAndSession,
27516
27660
  setTopicAgentAndClearSession: () => setTopicAgentAndClearSession,
27517
27661
  setTopicAgent: () => setTopicAgent,
27662
+ setSurfaceScopeRequired: () => setSurfaceScopeRequired,
27518
27663
  setSessionForTopic: () => setSessionForTopic,
27519
27664
  setLastShownConfig: () => setLastShownConfig,
27520
27665
  setGlobalAiName: () => setGlobalAiName,
27521
27666
  setDmSessionId: () => setDmSessionId,
27667
+ setDefaultSurfaceScope: () => setDefaultSurfaceScope,
27522
27668
  setApiTopicConfig: () => setApiTopicConfig,
27523
27669
  setApiTopicAgent: () => setApiTopicAgent,
27524
27670
  setApiMessageReactions: () => setApiMessageReactions,
@@ -27551,6 +27697,7 @@ __export(exports_storage_public, {
27551
27697
  normalizeTopicSurface: () => normalizeTopicSurface,
27552
27698
  normalizeTopicState: () => normalizeTopicState,
27553
27699
  normalizeTopicKind: () => normalizeTopicKind,
27700
+ normalizeSurfaceScope: () => normalizeSurfaceScope,
27554
27701
  normalizeAiMode: () => normalizeAiMode,
27555
27702
  markPendingAskState: () => markPendingAskState,
27556
27703
  markPendingAskSources: () => markPendingAskSources,
@@ -27566,6 +27713,7 @@ __export(exports_storage_public, {
27566
27713
  isTopicVisible: () => isTopicVisible,
27567
27714
  isTopicSummaryFile: () => isTopicSummaryFile,
27568
27715
  isTopicBriefFile: () => isTopicBriefFile,
27716
+ isSurfaceScopeRequired: () => isSurfaceScopeRequired,
27569
27717
  isRuntimeProcessLeaseAlive: () => isRuntimeProcessLeaseAlive,
27570
27718
  inferTopicKind: () => inferTopicKind,
27571
27719
  inferAiMode: () => inferAiMode,
@@ -27618,6 +27766,7 @@ __export(exports_storage_public, {
27618
27766
  findRecentUserMessage: () => findRecentUserMessage,
27619
27767
  findLastSessionIdForAgent: () => findLastSessionIdForAgent,
27620
27768
  describePendingAskState: () => describePendingAskState,
27769
+ deleteTopicStats: () => deleteTopicStats,
27621
27770
  deleteTopicBrief: () => deleteTopicBrief,
27622
27771
  deleteTopicArchiveState: () => deleteTopicArchiveState,
27623
27772
  deleteTopic: () => deleteTopic,
@@ -27626,6 +27775,7 @@ __export(exports_storage_public, {
27626
27775
  deleteMessagesForTopic: () => deleteMessagesForTopic,
27627
27776
  deleteApiTopicConfig: () => deleteApiTopicConfig,
27628
27777
  defaultTopicSurface: () => defaultTopicSurface,
27778
+ defaultSurfaceScope: () => defaultSurfaceScope,
27629
27779
  db: () => db2,
27630
27780
  createTasks: () => createTasks,
27631
27781
  createPendingAsk: () => createPendingAsk,
@@ -28485,7 +28635,7 @@ var init_spec = __esm(() => {
28485
28635
  });
28486
28636
 
28487
28637
  // ../../packages/mcp-host/src/manifest.ts
28488
- import { existsSync as existsSync29, mkdirSync as mkdirSync26, readFileSync as readFileSync24, renameSync as renameSync13, writeFileSync as writeFileSync19 } from "fs";
28638
+ import { existsSync as existsSync29, mkdirSync as mkdirSync26, readFileSync as readFileSync24, renameSync as renameSync13, writeFileSync as writeFileSync20 } from "fs";
28489
28639
  import { dirname as dirname17 } from "path";
28490
28640
  import { z as z14 } from "zod";
28491
28641
 
@@ -28552,7 +28702,7 @@ class McpManifest {
28552
28702
  servers: [...this.entries.values()]
28553
28703
  };
28554
28704
  const tmp = `${this.file}.tmp-${process.pid}`;
28555
- writeFileSync19(tmp, `${JSON.stringify(payload, null, 2)}
28705
+ writeFileSync20(tmp, `${JSON.stringify(payload, null, 2)}
28556
28706
  `);
28557
28707
  renameSync13(tmp, this.file);
28558
28708
  }
@@ -28587,7 +28737,7 @@ import {
28587
28737
  readFileSync as readFileSync25,
28588
28738
  renameSync as renameSync14,
28589
28739
  unlinkSync as unlinkSync21,
28590
- writeFileSync as writeFileSync20
28740
+ writeFileSync as writeFileSync21
28591
28741
  } from "fs";
28592
28742
  import { connect, createServer as createServer3 } from "net";
28593
28743
  import { join as join38 } from "path";
@@ -28904,7 +29054,7 @@ class McpHost {
28904
29054
  mkdirSync27(this.portsDir, { recursive: true });
28905
29055
  const file = join38(this.portsDir, fileName);
28906
29056
  const tmp = `${file}.tmp-${process.pid}`;
28907
- writeFileSync20(tmp, String(port));
29057
+ writeFileSync21(tmp, String(port));
28908
29058
  renameSync14(tmp, file);
28909
29059
  } catch (e) {
28910
29060
  this.log("warn", "Failed to write MCP port file", { fileName, port, err: String(e) });
@@ -29022,7 +29172,7 @@ import {
29022
29172
  readFileSync as readFileSync26,
29023
29173
  rmSync as rmSync9,
29024
29174
  statSync as statSync18,
29025
- writeFileSync as writeFileSync21
29175
+ writeFileSync as writeFileSync22
29026
29176
  } from "fs";
29027
29177
  import { basename as basename10, extname as extname4, join as join39 } from "path";
29028
29178
  function safeExtension(filename) {
@@ -29112,7 +29262,7 @@ class NodeFileStore {
29112
29262
  ...access.visibility ? { visibility: access.visibility } : {}
29113
29263
  };
29114
29264
  copyFileSync4(absPath, savedPath);
29115
- writeFileSync21(this.#metadataPath(fileId), JSON.stringify(metadata), { mode: 384 });
29265
+ writeFileSync22(this.#metadataPath(fileId), JSON.stringify(metadata), { mode: 384 });
29116
29266
  return this.#attachment(fileId, metadata);
29117
29267
  } catch (error2) {
29118
29268
  rmSync9(savedPath, { force: true });
@@ -29305,12 +29455,38 @@ import {
29305
29455
  readFileSync as readFileSync27,
29306
29456
  renameSync as renameSync15,
29307
29457
  unlinkSync as unlinkSync22,
29308
- writeFileSync as writeFileSync22
29458
+ writeFileSync as writeFileSync23
29309
29459
  } from "fs";
29310
29460
  import { dirname as dirname18, resolve as resolve21 } from "path";
29311
29461
  function jsonError(status, error2) {
29312
29462
  return Response.json({ ok: false, error: error2 }, { status });
29313
29463
  }
29464
+ function requestSurfaceScope(req) {
29465
+ const header = req.headers.get(NODE_RUNTIME_SURFACE_SCOPE_HEADER);
29466
+ if (header === null)
29467
+ return {};
29468
+ const scope = header.trim();
29469
+ return { surfaceScope: scope ? scope : null };
29470
+ }
29471
+ function topicInRequestScope(req, topic) {
29472
+ const scope = requestSurfaceScope(req);
29473
+ if (!("surfaceScope" in scope))
29474
+ return true;
29475
+ const roomScope = topic.surfaceScope ?? null;
29476
+ if (roomScope === null)
29477
+ return req.headers.get(NODE_RUNTIME_SURFACE_SCOPE_STRICT_HEADER) !== "1";
29478
+ return roomScope === (scope.surfaceScope ?? null);
29479
+ }
29480
+ function gatewayVisibleTopics(req) {
29481
+ const scope = requestSurfaceScope(req);
29482
+ if (!("surfaceScope" in scope))
29483
+ return getVisibleTopics({ surface: "otium" });
29484
+ const own = getVisibleTopics({ surface: "otium", surfaceScope: scope.surfaceScope ?? null });
29485
+ const strict = req.headers.get(NODE_RUNTIME_SURFACE_SCOPE_STRICT_HEADER) === "1";
29486
+ if (strict || scope.surfaceScope === null)
29487
+ return own;
29488
+ return own.concat(getVisibleTopics({ surface: "otium", surfaceScope: null }));
29489
+ }
29314
29490
  function topicServiceError(error2) {
29315
29491
  const status = error2.code === "TOPIC_NOT_FOUND" ? 404 : error2.code === "TOPIC_FORBIDDEN" ? 403 : 400;
29316
29492
  return jsonError(status, error2.message);
@@ -29365,7 +29541,21 @@ function runtimeEvent(event) {
29365
29541
  createdAt: event.createdAt
29366
29542
  };
29367
29543
  }
29368
- function createRuntimeContractEventStream(req, after, topicId) {
29544
+ function createRuntimeContractEventStream(req, after, topicId, scope = {}) {
29545
+ const scoped = "surfaceScope" in scope;
29546
+ const wanted = scope.surfaceScope ?? null;
29547
+ const visible = new Map;
29548
+ const eventInScope = (eventTopicId) => {
29549
+ if (!scoped)
29550
+ return true;
29551
+ const cached = visible.get(eventTopicId);
29552
+ if (cached !== undefined)
29553
+ return cached;
29554
+ const roomScope = getTopic(eventTopicId)?.surfaceScope ?? null;
29555
+ const allowed = roomScope === null || roomScope === wanted;
29556
+ visible.set(eventTopicId, allowed);
29557
+ return allowed;
29558
+ };
29369
29559
  let cursor = Math.max(0, after);
29370
29560
  return createPollingSseStream(req, {
29371
29561
  ready: { v: NODE_RUNTIME_CONTRACT_VERSION, cursor },
@@ -29376,7 +29566,7 @@ function createRuntimeContractEventStream(req, after, topicId) {
29376
29566
  break;
29377
29567
  for (const event of events) {
29378
29568
  cursor = event.seq;
29379
- if (!topicId || event.topicId === topicId) {
29569
+ if ((!topicId || event.topicId === topicId) && eventInScope(event.topicId)) {
29380
29570
  send("runtime", runtimeEvent(event), event.seq);
29381
29571
  }
29382
29572
  }
@@ -29387,8 +29577,8 @@ function createRuntimeContractEventStream(req, after, topicId) {
29387
29577
  }
29388
29578
  });
29389
29579
  }
29390
- function createEventStream(req, userId, after) {
29391
- const allowedTopics = new Set(topicsForUser(userId).map((topic) => topic.id));
29580
+ function createEventStream(req, userId, after, surface) {
29581
+ const allowedTopics = new Set(topicsForUser(userId, surface).map((topic) => topic.id));
29392
29582
  let cursor = Math.max(0, after);
29393
29583
  return createPollingSseStream(req, {
29394
29584
  ready: { protocolVersion: NODE_CONTROL_PROTOCOL_VERSION, cursor },
@@ -29401,7 +29591,8 @@ function createEventStream(req, userId, after) {
29401
29591
  cursor = event.seq;
29402
29592
  if (event.type === "topic-created" || event.type === "topic-updated") {
29403
29593
  const topic = getTopic(event.topicId);
29404
- if (topic && isParticipant(topic, userId))
29594
+ const admissible = topic && isParticipant(topic, userId) && (!surface || topic.surface === surface);
29595
+ if (admissible)
29405
29596
  allowedTopics.add(event.topicId);
29406
29597
  else
29407
29598
  allowedTopics.delete(event.topicId);
@@ -29453,6 +29644,10 @@ function createNodeControlHandler(options) {
29453
29644
  if (body.v !== NODE_RUNTIME_CONTRACT_VERSION)
29454
29645
  return jsonError(400, "Unsupported v");
29455
29646
  const topicId = requiredText(body.topicId, "topicId");
29647
+ const turnTopic = getTopic(topicId);
29648
+ if (turnTopic && !topicInRequestScope(req, turnTopic)) {
29649
+ return jsonError(404, "Topic not found");
29650
+ }
29456
29651
  const userId = requiredText(body.userId, "userId");
29457
29652
  const actorUserId = body.actorUserId === undefined ? undefined : requiredText(body.actorUserId, "actorUserId");
29458
29653
  const actorLabel = body.actorLabel === undefined ? undefined : requiredText(body.actorLabel, "actorLabel");
@@ -29460,12 +29655,19 @@ function createNodeControlHandler(options) {
29460
29655
  const text2 = requiredText(body.text, "text");
29461
29656
  const clientMessageId = requiredText(body.clientMessageId, "clientMessageId");
29462
29657
  const requestId = body.requestId === undefined ? undefined : requiredText(body.requestId, "requestId");
29658
+ const threadRootId = body.threadRootId === undefined ? undefined : requiredText(body.threadRootId, "threadRootId");
29463
29659
  const topic = getTopic(topicId);
29464
29660
  if (!topic)
29465
29661
  return jsonError(404, "Topic not found");
29466
29662
  if (!topic.participants.some((participant) => participant.userId === userId)) {
29467
29663
  return jsonError(404, "Topic not found");
29468
29664
  }
29665
+ if (threadRootId) {
29666
+ const root = getApiMessage(topicId, threadRootId);
29667
+ if (!root || root.deleted || root.threadRootId) {
29668
+ return jsonError(400, "threadRootId does not identify a thread root in this topic");
29669
+ }
29670
+ }
29469
29671
  const submission = submitRuntimeGatewayTurn({
29470
29672
  topic,
29471
29673
  userId,
@@ -29475,7 +29677,8 @@ function createNodeControlHandler(options) {
29475
29677
  text: text2,
29476
29678
  clientMessageId,
29477
29679
  requestId,
29478
- allowAutoContinue: body.allowAutoContinue !== false
29680
+ allowAutoContinue: body.allowAutoContinue !== false,
29681
+ ...threadRootId ? { threadRootId } : {}
29479
29682
  });
29480
29683
  return Response.json({
29481
29684
  ok: true,
@@ -29492,13 +29695,15 @@ function createNodeControlHandler(options) {
29492
29695
  if (req.method === "GET" && runtimePath === "/events") {
29493
29696
  const parsed = Number.parseInt(url.searchParams.get("after") ?? "0", 10);
29494
29697
  const topicId = url.searchParams.get("topicId")?.trim() || undefined;
29495
- return createRuntimeContractEventStream(req, Number.isFinite(parsed) ? parsed : 0, topicId);
29698
+ return createRuntimeContractEventStream(req, Number.isFinite(parsed) ? parsed : 0, topicId, requestSurfaceScope(req));
29496
29699
  }
29497
29700
  const runtimeMessagesMatch = runtimePath.match(/^\/topics\/([^/]+)\/messages$/);
29498
29701
  if (runtimeMessagesMatch && req.method === "GET") {
29499
29702
  const topicId = decodeURIComponent(runtimeMessagesMatch[1]);
29500
- if (!getTopic(topicId))
29703
+ const messagesTopic = getTopic(topicId);
29704
+ if (!messagesTopic || !topicInRequestScope(req, messagesTopic)) {
29501
29705
  return jsonError(404, "Topic not found");
29706
+ }
29502
29707
  const cursor = url.searchParams.get("cursor");
29503
29708
  const parsedLimit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10);
29504
29709
  const result = listApiMessages(topicId, {
@@ -29508,7 +29713,7 @@ function createNodeControlHandler(options) {
29508
29713
  return Response.json({ ok: true, v: NODE_RUNTIME_CONTRACT_VERSION, ...result });
29509
29714
  }
29510
29715
  if (req.method === "GET" && runtimePath === "/topics") {
29511
- const topics = getVisibleTopics({ surface: "otium" });
29716
+ const topics = gatewayVisibleTopics(req);
29512
29717
  return Response.json({
29513
29718
  ok: true,
29514
29719
  v: NODE_RUNTIME_CONTRACT_VERSION,
@@ -29531,6 +29736,7 @@ function createNodeControlHandler(options) {
29531
29736
  userId,
29532
29737
  kind: "agent",
29533
29738
  surface: "otium",
29739
+ ...requestSurfaceScope(req),
29534
29740
  ...agent ? { agent } : {}
29535
29741
  });
29536
29742
  return Response.json({ ok: true, v: NODE_RUNTIME_CONTRACT_VERSION, topic }, { status: 201 });
@@ -29542,7 +29748,7 @@ function createNodeControlHandler(options) {
29542
29748
  return jsonError(400, "Unsupported v");
29543
29749
  const topicId = decodeURIComponent(importMatch[1]);
29544
29750
  const topic = getTopic(topicId);
29545
- if (!topic)
29751
+ if (!topic || !topicInRequestScope(req, topic))
29546
29752
  return jsonError(404, "Topic not found");
29547
29753
  if (!Array.isArray(body.messages)) {
29548
29754
  return jsonError(400, "messages must be an array");
@@ -29587,6 +29793,8 @@ function createNodeControlHandler(options) {
29587
29793
  const runtimeTopicMatch = runtimePath.match(/^\/topics\/([^/]+)$/);
29588
29794
  if (runtimeTopicMatch && req.method === "GET") {
29589
29795
  const topic = getTopic(decodeURIComponent(runtimeTopicMatch[1]));
29796
+ if (topic && !topicInRequestScope(req, topic))
29797
+ return jsonError(404, "Topic not found");
29590
29798
  if (!topic)
29591
29799
  return jsonError(404, "Topic not found");
29592
29800
  return Response.json({ ok: true, v: NODE_RUNTIME_CONTRACT_VERSION, topic });
@@ -29610,7 +29818,7 @@ function createNodeControlHandler(options) {
29610
29818
  }
29611
29819
  if (req.method === "GET" && path === "/session") {
29612
29820
  const userId = requiredText(url.searchParams.get("user"), "user");
29613
- ensurePersonalGeneral(userId);
29821
+ ensurePersonalGeneral(userId, requestedSurface(url));
29614
29822
  return Response.json({
29615
29823
  ok: true,
29616
29824
  protocolVersion: NODE_CONTROL_PROTOCOL_VERSION,
@@ -29681,7 +29889,7 @@ function createNodeControlHandler(options) {
29681
29889
  if (req.method === "GET" && path === "/events") {
29682
29890
  const userId = requiredText(url.searchParams.get("user"), "user");
29683
29891
  const parsed = Number.parseInt(url.searchParams.get("after") ?? "0", 10);
29684
- return createEventStream(req, userId, Number.isFinite(parsed) ? parsed : 0);
29892
+ return createEventStream(req, userId, Number.isFinite(parsed) ? parsed : 0, requestedSurface(url));
29685
29893
  }
29686
29894
  const messagesMatch = path.match(/^\/topics\/([^/]+)\/messages$/);
29687
29895
  if (messagesMatch && req.method === "GET") {
@@ -29878,7 +30086,7 @@ function writeNodeDaemonInfo(port, startedAt) {
29878
30086
  };
29879
30087
  mkdirSync29(dirname18(NODE_DAEMON_INFO_PATH), { recursive: true });
29880
30088
  const temporary = `${NODE_DAEMON_INFO_PATH}.${process.pid}.${randomUUID25()}.tmp`;
29881
- writeFileSync22(temporary, `${JSON.stringify(info, null, 2)}
30089
+ writeFileSync23(temporary, `${JSON.stringify(info, null, 2)}
29882
30090
  `, { mode: 384 });
29883
30091
  chmodSync6(temporary, 384);
29884
30092
  renameSync15(temporary, NODE_DAEMON_INFO_PATH);
@@ -29966,7 +30174,7 @@ async function stopNodeDaemon(timeoutMs = 3000) {
29966
30174
  throw new Error(`node shutdown returned HTTP ${response.status}`);
29967
30175
  return true;
29968
30176
  }
29969
- var NODE_CONTROL_PROTOCOL_VERSION = 1, NODE_CONTROL_BASE_PATH = "/api/v1/control", NODE_RUNTIME_CONTRACT_VERSION = 1, NODE_RUNTIME_CONTRACT_BASE_PATH, NODE_DAEMON_ROLE = "node-daemon", NODE_DAEMON_INFO_PATH, NODE_VERSION, ControlRequestError;
30177
+ var NODE_CONTROL_PROTOCOL_VERSION = 1, NODE_CONTROL_BASE_PATH = "/api/v1/control", NODE_RUNTIME_CONTRACT_VERSION = 1, NODE_RUNTIME_SURFACE_SCOPE_HEADER = "x-negotium-surface-scope", NODE_RUNTIME_SURFACE_SCOPE_STRICT_HEADER = "x-negotium-surface-scope-strict", NODE_RUNTIME_CONTRACT_BASE_PATH, NODE_DAEMON_ROLE = "node-daemon", NODE_DAEMON_INFO_PATH, NODE_VERSION, ControlRequestError;
29970
30178
  var init_control = __esm(async () => {
29971
30179
  await init_node_host();
29972
30180
  await init_files();
@@ -32895,8 +33103,10 @@ function activeContextBreakdown(state) {
32895
33103
  if (!topic)
32896
33104
  return;
32897
33105
  const messages = activeMessages(state);
32898
- const latest = latestUsageMessage(messages);
32899
- const stored = state.topicUsage[topic.id]?.currentSession;
33106
+ const topicUsage = state.topicUsage[topic.id];
33107
+ const stored = topicUsage?.currentSession;
33108
+ const contextWasReset = Boolean(topicUsage) && !stored;
33109
+ const latest = contextWasReset ? undefined : latestUsageMessage(messages);
32900
33110
  const confirmed = latest?.usage.context ?? stored?.contextTokens;
32901
33111
  const contextWindow = latest?.usage.contextWindow ?? stored?.contextWindow;
32902
33112
  if (confirmed === undefined || !contextWindow)
@@ -34184,10 +34394,10 @@ function topicPickerHints(topicPickerRoot) {
34184
34394
  `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N new \xB7 Ctrl-D delete \xB7 ${exit}`,
34185
34395
  `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N new \xB7 Ctrl-D delete \xB7 ${shortExit}`,
34186
34396
  `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N new \xB7 Ctrl-D delete \xB7 ${exit}`,
34187
- `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N/D/P \xB7 ${shortExit}`,
34188
- "\u2191\u2193 \xB7 Enter \xB7 type to filter \xB7 Ctrl-N/D/P",
34189
- "type to filter \xB7 Ctrl-N/D/P",
34190
- "Ctrl-N/D/P"
34397
+ `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N/D \xB7 ${shortExit}`,
34398
+ "\u2191\u2193 \xB7 Enter \xB7 type to filter \xB7 Ctrl-N/D",
34399
+ "type to filter \xB7 Ctrl-N/D",
34400
+ "Ctrl-N/D"
34191
34401
  ];
34192
34402
  return [...new Set(candidates)].sort((a, b) => displayWidth(b) - displayWidth(a));
34193
34403
  }
@@ -35394,7 +35604,7 @@ class EmbeddedNegotiumClient {
35394
35604
  try {
35395
35605
  if (this.#startNode)
35396
35606
  this.#node = await startDefaultNode({ port: this.#port });
35397
- ensurePersonalGeneral(this.#userId);
35607
+ ensurePersonalGeneral(this.#userId, "terminal");
35398
35608
  } catch (error2) {
35399
35609
  this.#unsubscribe?.();
35400
35610
  this.#unsubscribe = null;
@@ -35778,7 +35988,7 @@ class RemoteNegotiumClient {
35778
35988
  }
35779
35989
  }
35780
35990
  async#openEventStream(after, signal) {
35781
- const response = await fetch(`${this.#baseUrl}${NODE_CONTROL_BASE_PATH}/events?user=${encodeURIComponent(this.#userId)}&after=${after}`, {
35991
+ const response = await fetch(`${this.#baseUrl}${NODE_CONTROL_BASE_PATH}/events?user=${encodeURIComponent(this.#userId)}&after=${after}&surface=terminal`, {
35782
35992
  signal,
35783
35993
  headers: {
35784
35994
  accept: "text/event-stream",
@@ -39864,7 +40074,7 @@ function startTelegramAdapter(opts) {
39864
40074
  if (initialForumChatId !== undefined && !forumMode) {
39865
40075
  logger.warn({ forumChatId: initialForumChatId }, "telegram adapter: forumChatId set but client lacks createForumTopic \u2014 forum mode disabled");
39866
40076
  }
39867
- const personalGeneral = ensurePersonalGeneral(userId);
40077
+ const personalGeneral = ensurePersonalGeneral(userId, "telegram");
39868
40078
  const byKey = new Map;
39869
40079
  const byTopic = new Map;
39870
40080
  const targetByQueryId = new Map;
@@ -40111,7 +40321,9 @@ function startTelegramAdapter(opts) {
40111
40321
  return true;
40112
40322
  }
40113
40323
  if (!store.isFlagSet(SURFACE_BACKFILL_FLAG)) {
40114
- const mappedIds = [...new Set(store.load().map((mapping) => mapping.topicId))];
40324
+ const mappedIds = [
40325
+ ...new Set(store.load().map((mapping) => mapping.topicId).filter((topicId) => getTopic(topicId)?.kind !== "manager"))
40326
+ ];
40115
40327
  const moved = setTopicSurfaces(mappedIds, "telegram");
40116
40328
  store.setFlag(SURFACE_BACKFILL_FLAG);
40117
40329
  if (moved > 0) {
@@ -41360,45 +41572,101 @@ var init_join_status = __esm(() => {
41360
41572
  });
41361
41573
 
41362
41574
  // ../../adapters/otium/src/control-protocol.ts
41363
- var OTIUM_ADAPTER_CONTROL_PREFIX = "/api/v1/adapter/otium", OTIUM_ADAPTER_CONTROL_HEADER = "x-negotium-adapter-token";
41575
+ var OTIUM_ADAPTER_CONTROL_PREFIX = "/api/v1/adapter/otium", OTIUM_ADAPTER_CONTROL_HEADER = "x-negotium-adapter-token", OTIUM_WORKSPACES_CONTROL_PATH = "/_workspaces", OTIUM_RELAYED_HEADER = "x-negotium-otium-relayed";
41364
41576
 
41365
41577
  // ../../adapters/otium/src/central.ts
41578
+ function cellState(cellId) {
41579
+ return cells.get(cellId) ?? null;
41580
+ }
41581
+ function firstCell() {
41582
+ for (const cell of cells.values())
41583
+ return cell;
41584
+ return null;
41585
+ }
41586
+ function attachOtiumCentralCell(join42) {
41587
+ cells.set(join42.cellId, {
41588
+ join: join42,
41589
+ nodesCache: null,
41590
+ verifyCache: new Map,
41591
+ tokenCache: new Map
41592
+ });
41593
+ }
41594
+ function detachOtiumCentralCell(cellId) {
41595
+ return cells.delete(cellId);
41596
+ }
41597
+ function attachedOtiumCells() {
41598
+ return [...cells.values()].map((cell) => cell.join);
41599
+ }
41366
41600
  function configureOtiumCentral(join42) {
41367
- joinConfig = join42;
41368
- resetPeerCentralCaches();
41601
+ cells.clear();
41602
+ if (join42)
41603
+ attachOtiumCentralCell(join42);
41369
41604
  }
41370
- function otiumCentralConfig() {
41371
- return joinConfig;
41605
+ function isOtiumCentralConfigured() {
41606
+ return cells.size > 0;
41372
41607
  }
41373
- function centralFetch(path, init) {
41374
- if (!joinConfig)
41375
- throw new Error("otium: join credentials missing");
41376
- return fetch(`${joinConfig.central}${path}`, {
41608
+ function centralFetch(cell, path, init) {
41609
+ return fetch(`${cell.join.central}${path}`, {
41377
41610
  ...init,
41378
41611
  headers: {
41379
- authorization: `Bearer ${joinConfig.secret}`,
41612
+ authorization: `Bearer ${cell.join.secret}`,
41380
41613
  "content-type": "application/json",
41381
41614
  ...init.headers ?? {}
41382
41615
  },
41383
41616
  signal: AbortSignal.timeout(5000)
41384
41617
  });
41385
41618
  }
41386
- async function listPeerNodes(opts = {}) {
41387
- if (!opts.fresh && nodesCache && Date.now() - nodesCache.at < NODES_CACHE_MS) {
41388
- return nodesCache.nodes;
41619
+ async function discover(cell, fresh) {
41620
+ if (!fresh && cell.nodesCache && Date.now() - cell.nodesCache.at < NODES_CACHE_MS) {
41621
+ return cell.nodesCache.nodes;
41389
41622
  }
41390
- const response = await centralFetch("/peer/nodes", { method: "GET" });
41623
+ const response = await centralFetch(cell, "/peer/nodes", { method: "GET" });
41391
41624
  const body = await response.json();
41392
41625
  if (!response.ok || !body.ok || !Array.isArray(body.nodes)) {
41393
41626
  throw new Error(`otium: node discovery failed: ${body.error ?? response.status}`);
41394
41627
  }
41395
- nodesCache = { nodes: body.nodes, workspaceId: body.workspaceId ?? "", at: Date.now() };
41396
- return body.nodes;
41628
+ const nodes = body.nodes.map((node) => ({ ...node, viaCellId: cell.join.cellId }));
41629
+ cell.nodesCache = { nodes, workspaceId: body.workspaceId ?? "", at: Date.now() };
41630
+ return nodes;
41397
41631
  }
41398
- async function selfPeerNode() {
41399
- const nodes = await listPeerNodes();
41632
+ async function listPeerNodesForCell(cellId, opts = {}) {
41633
+ const cell = cellState(cellId);
41634
+ if (!cell)
41635
+ throw new Error(`otium: not attached to cell ${cellId}`);
41636
+ return discover(cell, opts.fresh === true);
41637
+ }
41638
+ async function listPeerNodes(opts = {}) {
41639
+ if (cells.size === 0)
41640
+ throw new Error("otium: join credentials missing");
41641
+ const results = await Promise.allSettled([...cells.values()].map((cell) => discover(cell, opts.fresh === true)));
41642
+ const nodes = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
41643
+ const failures = results.filter((result) => result.status === "rejected");
41644
+ if (failures.length === results.length) {
41645
+ throw failures[0].reason;
41646
+ }
41647
+ for (const failure of failures) {
41648
+ logger.warn({ err: failure.reason }, "otium: discovery failed");
41649
+ }
41650
+ return nodes;
41651
+ }
41652
+ async function selfPeerNodeForCell(cellId) {
41653
+ const nodes = await listPeerNodesForCell(cellId);
41400
41654
  return nodes.find((node) => node.self) ?? null;
41401
41655
  }
41656
+ async function selfPeerNode() {
41657
+ const cell = firstCell();
41658
+ if (!cell)
41659
+ throw new Error("otium: join credentials missing");
41660
+ return selfPeerNodeForCell(cell.join.cellId);
41661
+ }
41662
+ async function peerWorkspaceIdForCell(cellId) {
41663
+ const cell = cellState(cellId);
41664
+ if (!cell)
41665
+ return null;
41666
+ if (!cell.nodesCache?.workspaceId)
41667
+ await discover(cell, true);
41668
+ return cell.nodesCache?.workspaceId || null;
41669
+ }
41402
41670
  async function resolvePeerNodeByCellId(cellId) {
41403
41671
  const find = (nodes) => nodes.find((node) => node.cellId === cellId) ?? null;
41404
41672
  const cached = find(await listPeerNodes());
@@ -41406,36 +41674,39 @@ async function resolvePeerNodeByCellId(cellId) {
41406
41674
  return cached;
41407
41675
  return find(await listPeerNodes({ fresh: true }));
41408
41676
  }
41409
- async function mintPeerToken(toCellId) {
41410
- const cached = tokenCache.get(toCellId);
41677
+ async function mintPeerToken(target) {
41678
+ const cell = cellState(target.viaCellId);
41679
+ if (!cell)
41680
+ throw new Error("otium: join credentials missing");
41681
+ const cached = cell.tokenCache.get(target.cellId);
41411
41682
  if (cached && cached.expiresAtMs - Date.now() > 30000)
41412
41683
  return cached.token;
41413
- const response = await centralFetch("/peer/token", {
41684
+ const response = await centralFetch(cell, "/peer/token", {
41414
41685
  method: "POST",
41415
- body: JSON.stringify({ toCellId })
41686
+ body: JSON.stringify({ toCellId: target.cellId })
41416
41687
  });
41417
41688
  const body = await response.json();
41418
41689
  if (!response.ok || !body.ok || !body.token) {
41419
41690
  throw new Error(`otium: peer token mint failed: ${body.error ?? response.status}`);
41420
41691
  }
41421
- tokenCache.set(toCellId, {
41692
+ cell.tokenCache.set(target.cellId, {
41422
41693
  token: body.token,
41423
41694
  expiresAtMs: Date.parse(body.expiresAt ?? "") || Date.now() + 60000
41424
41695
  });
41425
41696
  return body.token;
41426
41697
  }
41427
- async function verifyPeerToken(token) {
41428
- const cached = verifyCache.get(token);
41698
+ async function verifyAgainstCell(cell, token) {
41699
+ const cached = cell.verifyCache.get(token);
41429
41700
  if (cached && Date.now() - cached.at < VERIFY_CACHE_MS)
41430
41701
  return cached.verified;
41431
41702
  let response;
41432
41703
  try {
41433
- response = await centralFetch("/peer/verify", {
41704
+ response = await centralFetch(cell, "/peer/verify", {
41434
41705
  method: "POST",
41435
41706
  body: JSON.stringify({ token })
41436
41707
  });
41437
41708
  } catch (err2) {
41438
- logger.warn({ err: err2 }, "otium: central verify unreachable");
41709
+ logger.warn({ err: err2, cellId: cell.join.cellId }, "otium: central verify unreachable");
41439
41710
  return null;
41440
41711
  }
41441
41712
  const body = await response.json().catch(() => null);
@@ -41446,25 +41717,39 @@ async function verifyPeerToken(token) {
41446
41717
  fromCellId: body.fromCellId,
41447
41718
  fromNodeName: body.fromNodeName,
41448
41719
  fromIsPrimary: body.fromIsPrimary,
41449
- expiresAt: body.expiresAt
41720
+ expiresAt: body.expiresAt,
41721
+ viaCellId: cell.join.cellId
41450
41722
  };
41451
- verifyCache.set(token, { verified, at: Date.now() });
41452
- for (const [key, entry] of verifyCache) {
41723
+ cell.verifyCache.set(token, { verified, at: Date.now() });
41724
+ for (const [key, entry] of cell.verifyCache) {
41453
41725
  if (Date.now() - entry.at > VERIFY_CACHE_MS)
41454
- verifyCache.delete(key);
41726
+ cell.verifyCache.delete(key);
41455
41727
  }
41456
41728
  return verified;
41457
41729
  }
41458
- function resetPeerCentralCaches() {
41459
- nodesCache = null;
41460
- verifyCache.clear();
41461
- tokenCache.clear();
41730
+ async function verifyPeerToken(token) {
41731
+ const rejectedAt = rejectCache.get(token);
41732
+ if (rejectedAt !== undefined && Date.now() - rejectedAt < REJECT_CACHE_MS)
41733
+ return null;
41734
+ for (const cell of cells.values()) {
41735
+ const verified = await verifyAgainstCell(cell, token);
41736
+ if (verified) {
41737
+ rejectCache.delete(token);
41738
+ return verified;
41739
+ }
41740
+ }
41741
+ rejectCache.set(token, Date.now());
41742
+ for (const [key, at] of rejectCache) {
41743
+ if (Date.now() - at > REJECT_CACHE_MS)
41744
+ rejectCache.delete(key);
41745
+ }
41746
+ return null;
41462
41747
  }
41463
- var NODES_CACHE_MS = 30000, VERIFY_CACHE_MS = 30000, joinConfig = null, nodesCache = null, verifyCache, tokenCache;
41748
+ var NODES_CACHE_MS = 30000, VERIFY_CACHE_MS = 30000, cells, REJECT_CACHE_MS = 2000, rejectCache;
41464
41749
  var init_central = __esm(async () => {
41465
41750
  await init_src();
41466
- verifyCache = new Map;
41467
- tokenCache = new Map;
41751
+ cells = new Map;
41752
+ rejectCache = new Map;
41468
41753
  });
41469
41754
 
41470
41755
  // ../../adapters/otium/src/protocol.ts
@@ -41539,7 +41824,7 @@ async function forwardCanonicalTool(capability, request) {
41539
41824
  if (!hub?.isPrimary || hub.self)
41540
41825
  return { error: "canonical hub is unavailable", status: 503 };
41541
41826
  try {
41542
- const peerToken = await mintPeerToken(hub.cellId);
41827
+ const peerToken = await mintPeerToken(hub);
41543
41828
  const response = await fetch(`${hub.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/canonical-mcp`, {
41544
41829
  method: "POST",
41545
41830
  headers: { authorization: `Bearer ${peerToken}`, "content-type": "application/json" },
@@ -41712,6 +41997,7 @@ __export(exports_join, {
41712
41997
  saveJoin: () => saveJoin,
41713
41998
  removeJoin: () => removeJoin,
41714
41999
  parseInviteCode: () => parseInviteCode,
42000
+ loadJoins: () => loadJoins,
41715
42001
  loadJoin: () => loadJoin,
41716
42002
  joinFilePath: () => joinFilePath,
41717
42003
  joinCredentialDigest: () => joinCredentialDigest,
@@ -41732,7 +42018,7 @@ import {
41732
42018
  rmSync as rmSync10,
41733
42019
  statSync as statSync20,
41734
42020
  unlinkSync as unlinkSync23,
41735
- writeFileSync as writeFileSync23
42021
+ writeFileSync as writeFileSync24
41736
42022
  } from "fs";
41737
42023
  import { dirname as dirname21, resolve as resolve24 } from "path";
41738
42024
  function joinFilePath() {
@@ -41813,7 +42099,7 @@ function withJoinCredentialLock(operation) {
41813
42099
  try {
41814
42100
  mkdirSync32(lockPath, { mode: 448 });
41815
42101
  created = true;
41816
- writeFileSync23(ownerPath, `${JSON.stringify(owner)}
42102
+ writeFileSync24(ownerPath, `${JSON.stringify(owner)}
41817
42103
  `, { mode: 384 });
41818
42104
  const ownerFd = openSync5(ownerPath, "r");
41819
42105
  try {
@@ -41883,82 +42169,60 @@ function normalizedJoin(join42) {
41883
42169
  function joinCredentialDigest(join42) {
41884
42170
  return createHash11("sha256").update(JSON.stringify(normalizedJoin(join42))).digest("base64url");
41885
42171
  }
41886
- function readPersistedJoin(path = joinFilePath()) {
42172
+ function readPersistedJoins(path = joinFilePath()) {
41887
42173
  if (!existsSync37(path))
41888
- return null;
42174
+ return [];
41889
42175
  const parsed = JSON.parse(readFileSync28(path, "utf-8"));
41890
42176
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
41891
42177
  throw new Error("persisted join credentials are not a JSON object");
41892
42178
  }
41893
- return normalizeJoin(parsed);
42179
+ const record = parsed;
42180
+ if (!Array.isArray(record.joins))
42181
+ return [normalizeJoin(record)];
42182
+ return record.joins.map((entry) => {
42183
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
42184
+ throw new Error("persisted join credentials contain a non-object entry");
42185
+ }
42186
+ return normalizeJoin(entry);
42187
+ });
41894
42188
  }
41895
42189
  function isJoinPersisted(join42) {
41896
42190
  try {
41897
- const persisted = readPersistedJoin();
41898
- return persisted !== null && joinsEqual(persisted, normalizedJoin(join42));
42191
+ const normalized = normalizedJoin(join42);
42192
+ return readPersistedJoins().some((persisted) => joinsEqual(persisted, normalized));
41899
42193
  } catch {
41900
42194
  return false;
41901
42195
  }
41902
42196
  }
41903
- function saveJoinWhileLocked(join42, options = {}) {
42197
+ function fsyncPath(target) {
42198
+ const fd = openSync5(target, "r");
42199
+ try {
42200
+ fsyncSync2(fd);
42201
+ } finally {
42202
+ closeSync5(fd);
42203
+ }
42204
+ }
42205
+ function writeJoins(joins, allowOverwrite) {
41904
42206
  const path = joinFilePath();
41905
42207
  const directory = dirname21(path);
41906
- const normalized = normalizedJoin(join42);
41907
- mkdirSync32(directory, { recursive: true });
41908
- if (existsSync37(path)) {
41909
- if (lstatSync(path).isSymbolicLink()) {
41910
- throw new Error(`refusing to replace symlinked Otium join file at ${path}`);
41911
- }
41912
- let existing = null;
41913
- try {
41914
- existing = readPersistedJoin(path);
41915
- } catch (error2) {
41916
- if (!options.replaceExisting) {
41917
- throw new Error(`existing Otium join file at ${path} is invalid; pass --replace to replace it`, { cause: error2 });
41918
- }
41919
- }
41920
- if (existing && joinsEqual(existing, normalized)) {
41921
- chmodSync7(path, 384);
41922
- const fileFd = openSync5(path, "r");
41923
- try {
41924
- fsyncSync2(fileFd);
41925
- } finally {
41926
- closeSync5(fileFd);
41927
- }
41928
- const directoryFd = openSync5(directory, "r");
41929
- try {
41930
- fsyncSync2(directoryFd);
41931
- } finally {
41932
- closeSync5(directoryFd);
41933
- }
41934
- return path;
41935
- }
41936
- if (!options.replaceExisting) {
41937
- throw new Error(`this node is already joined${existing ? ` as ${existing.cellId}` : " with an invalid join file"}; pass --replace to replace its credentials`);
41938
- }
41939
- }
41940
42208
  const temporaryPath = resolve24(directory, `.otium-join.json.${process.pid}.${randomUUID28()}.tmp`);
42209
+ const payload = { v: JOIN_FILE_VERSION, joins };
41941
42210
  let fd;
41942
42211
  try {
41943
42212
  fd = openSync5(temporaryPath, "wx", 384);
41944
- writeFileSync23(fd, `${JSON.stringify(normalized, null, 2)}
42213
+ writeFileSync24(fd, `${JSON.stringify(payload, null, 2)}
41945
42214
  `, "utf8");
41946
42215
  fsyncSync2(fd);
41947
42216
  closeSync5(fd);
41948
42217
  fd = undefined;
41949
- if (options.replaceExisting) {
42218
+ if (allowOverwrite) {
41950
42219
  renameSync16(temporaryPath, path);
41951
42220
  } else {
41952
42221
  linkSync(temporaryPath, path);
41953
42222
  unlinkSync23(temporaryPath);
41954
42223
  }
41955
42224
  chmodSync7(path, 384);
41956
- const directoryFd = openSync5(directory, "r");
41957
- try {
41958
- fsyncSync2(directoryFd);
41959
- } finally {
41960
- closeSync5(directoryFd);
41961
- }
42225
+ fsyncPath(directory);
41962
42226
  } catch (error2) {
41963
42227
  if (fd !== undefined)
41964
42228
  closeSync5(fd);
@@ -41968,10 +42232,43 @@ function saveJoinWhileLocked(join42, options = {}) {
41968
42232
  }
41969
42233
  return path;
41970
42234
  }
42235
+ function saveJoinWhileLocked(join42, options = {}) {
42236
+ const path = joinFilePath();
42237
+ const directory = dirname21(path);
42238
+ const normalized = normalizedJoin(join42);
42239
+ mkdirSync32(directory, { recursive: true });
42240
+ if (!existsSync37(path))
42241
+ return writeJoins([normalized], false);
42242
+ if (lstatSync(path).isSymbolicLink()) {
42243
+ throw new Error(`refusing to replace symlinked Otium join file at ${path}`);
42244
+ }
42245
+ let existing = null;
42246
+ try {
42247
+ existing = readPersistedJoins(path);
42248
+ } catch (error2) {
42249
+ if (!options.replaceExisting) {
42250
+ throw new Error(`existing Otium join file at ${path} is invalid; pass --replace to replace it`, { cause: error2 });
42251
+ }
42252
+ }
42253
+ if (existing?.some((persisted) => joinsEqual(persisted, normalized))) {
42254
+ chmodSync7(path, 384);
42255
+ fsyncPath(path);
42256
+ fsyncPath(directory);
42257
+ return path;
42258
+ }
42259
+ if (!existing)
42260
+ return writeJoins([normalized], true);
42261
+ const conflict = existing.find((persisted) => persisted.cellId === normalized.cellId);
42262
+ if (conflict && !options.replaceExisting) {
42263
+ throw new Error(`this node is already joined as ${conflict.cellId}; pass --replace to replace its credentials`);
42264
+ }
42265
+ const next = conflict ? existing.map((persisted) => persisted.cellId === normalized.cellId ? normalized : persisted) : [...existing, normalized];
42266
+ return writeJoins(next, true);
42267
+ }
41971
42268
  function saveJoin(join42, options = {}) {
41972
42269
  return withJoinCredentialLock(() => saveJoinWhileLocked(join42, options));
41973
42270
  }
41974
- function removeJoin() {
42271
+ function removeJoin(cellId) {
41975
42272
  return withJoinCredentialLock(() => {
41976
42273
  const path = joinFilePath();
41977
42274
  if (!existsSync37(path))
@@ -41979,27 +42276,36 @@ function removeJoin() {
41979
42276
  if (lstatSync(path).isSymbolicLink()) {
41980
42277
  throw new Error(`refusing to remove symlinked Otium join file at ${path}`);
41981
42278
  }
41982
- unlinkSync23(path);
41983
- const directoryFd = openSync5(dirname21(path), "r");
41984
- try {
41985
- fsyncSync2(directoryFd);
41986
- } finally {
41987
- closeSync5(directoryFd);
42279
+ if (cellId) {
42280
+ let remaining;
42281
+ try {
42282
+ remaining = readPersistedJoins(path).filter((join42) => join42.cellId !== cellId);
42283
+ } catch {
42284
+ return false;
42285
+ }
42286
+ if (remaining.length === readPersistedJoins(path).length)
42287
+ return false;
42288
+ if (remaining.length > 0) {
42289
+ writeJoins(remaining, true);
42290
+ return true;
42291
+ }
41988
42292
  }
42293
+ unlinkSync23(path);
42294
+ fsyncPath(dirname21(path));
41989
42295
  return true;
41990
42296
  });
41991
42297
  }
41992
- function loadJoin() {
42298
+ function loadJoins() {
41993
42299
  const central = process.env.OTIUM_CENTRAL_URL?.trim();
41994
42300
  const cellId = process.env.OTIUM_CELL_ID?.trim();
41995
42301
  const secret = process.env.OTIUM_CELL_SECRET?.trim();
41996
42302
  const relay = process.env.OTIUM_RELAY_URL?.trim();
41997
42303
  if (central && cellId && secret) {
41998
42304
  try {
41999
- return normalizeJoin({ central, relay, cellId, secret });
42305
+ return [normalizeJoin({ central, relay, cellId, secret })];
42000
42306
  } catch (err2) {
42001
42307
  logger.warn({ err: err2 }, "otium: invalid OTIUM_CENTRAL_URL/OTIUM_CELL_ID/OTIUM_CELL_SECRET env");
42002
- return null;
42308
+ return [];
42003
42309
  }
42004
42310
  }
42005
42311
  if (central || cellId || secret) {
@@ -42007,18 +42313,18 @@ function loadJoin() {
42007
42313
  }
42008
42314
  const path = joinFilePath();
42009
42315
  if (!existsSync37(path))
42010
- return null;
42316
+ return [];
42011
42317
  try {
42012
- const parsed = JSON.parse(readFileSync28(path, "utf-8"));
42013
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
42014
- return null;
42015
- return normalizeJoin(parsed);
42318
+ return readPersistedJoins(path);
42016
42319
  } catch (err2) {
42017
42320
  logger.warn({ err: err2, path }, "otium: failed to read join file");
42018
- return null;
42321
+ return [];
42019
42322
  }
42020
42323
  }
42021
- var JOIN_LOCK_STALE_MS = 30000;
42324
+ function loadJoin() {
42325
+ return loadJoins()[0] ?? null;
42326
+ }
42327
+ var JOIN_LOCK_STALE_MS = 30000, JOIN_FILE_VERSION = 2;
42022
42328
  var init_join = __esm(async () => {
42023
42329
  await init_src();
42024
42330
  });
@@ -42172,7 +42478,7 @@ var init_runtime_bridge = __esm(async () => {
42172
42478
  return errorResult("Error: Hub node is no longer attached.");
42173
42479
  let token;
42174
42480
  try {
42175
- token = await mintPeerToken(hubNode.cellId);
42481
+ token = await mintPeerToken(hubNode);
42176
42482
  } catch (err2) {
42177
42483
  return errorResult(`Error: Failed to spawn on hub: ${err2.message}`);
42178
42484
  }
@@ -42212,7 +42518,7 @@ var init_runtime_bridge = __esm(async () => {
42212
42518
  return errorResult("Error: Hub node is no longer attached.");
42213
42519
  let token;
42214
42520
  try {
42215
- token = await mintPeerToken(hubNode.cellId);
42521
+ token = await mintPeerToken(hubNode);
42216
42522
  } catch (error2) {
42217
42523
  return errorResult(`Error: Failed to open hub question: ${error2.message}`);
42218
42524
  }
@@ -42268,7 +42574,7 @@ var init_runtime_bridge = __esm(async () => {
42268
42574
  if (!hubNode)
42269
42575
  return errorResult("Error: Hub node is no longer attached.");
42270
42576
  try {
42271
- const token = await mintPeerToken(hubNode.cellId);
42577
+ const token = await mintPeerToken(hubNode);
42272
42578
  const response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/self-config`, {
42273
42579
  method: "POST",
42274
42580
  headers: {
@@ -42299,7 +42605,7 @@ var init_runtime_bridge = __esm(async () => {
42299
42605
  return { ok: false, error: "hub node is no longer attached" };
42300
42606
  let token;
42301
42607
  try {
42302
- token = await mintPeerToken(hubNode.cellId);
42608
+ token = await mintPeerToken(hubNode);
42303
42609
  } catch (error2) {
42304
42610
  return { ok: false, error: `peer token mint failed: ${error2.message}` };
42305
42611
  }
@@ -42361,7 +42667,7 @@ var init_runtime_bridge = __esm(async () => {
42361
42667
  if (!hubNode)
42362
42668
  return { ok: false, error: "hub node is no longer attached" };
42363
42669
  try {
42364
- const token = await mintPeerToken(hubNode.cellId);
42670
+ const token = await mintPeerToken(hubNode);
42365
42671
  const form = new FormData;
42366
42672
  form.set("hostQueryId", request.bridge.hostQueryId);
42367
42673
  form.set("userId", request.userId);
@@ -42528,13 +42834,81 @@ var init_store2 = __esm(async () => {
42528
42834
  `);
42529
42835
  });
42530
42836
 
42837
+ // ../../adapters/otium/src/workspace-scope.ts
42838
+ import { createHash as createHash13 } from "crypto";
42839
+ import { mkdirSync as mkdirSync34, readFileSync as readFileSync29, writeFileSync as writeFileSync25 } from "fs";
42840
+ import { dirname as dirname22, resolve as resolve25 } from "path";
42841
+ function surfaceScopeFor(central, workspaceId) {
42842
+ const digest = createHash13("sha256").update(`${central.trim().replace(/\/+$/, "")}
42843
+ ${workspaceId.trim()}`).digest("hex");
42844
+ return `ws_${digest.slice(0, 24)}`;
42845
+ }
42846
+ function scopeCachePath() {
42847
+ return resolve25(DATA_DIR, "otium-workspace.json");
42848
+ }
42849
+ function readCache() {
42850
+ try {
42851
+ const parsed = JSON.parse(readFileSync29(scopeCachePath(), "utf-8"));
42852
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
42853
+ return {};
42854
+ return parsed;
42855
+ } catch {
42856
+ return {};
42857
+ }
42858
+ }
42859
+ function cachedSurfaceScope(join43) {
42860
+ const record = readCache()[join43.cellId];
42861
+ if (!record || record.central !== join43.central || !record.workspaceId)
42862
+ return null;
42863
+ return surfaceScopeFor(record.central, record.workspaceId);
42864
+ }
42865
+ function cacheSurfaceScope(join43, workspaceId) {
42866
+ const scope = surfaceScopeFor(join43.central, workspaceId);
42867
+ const path = scopeCachePath();
42868
+ try {
42869
+ mkdirSync34(dirname22(path), { recursive: true });
42870
+ const cache = readCache();
42871
+ cache[join43.cellId] = { central: join43.central, workspaceId, scope };
42872
+ writeFileSync25(path, `${JSON.stringify(cache, null, 2)}
42873
+ `, { mode: 384 });
42874
+ } catch (err2) {
42875
+ logger.warn({ err: err2, path }, "otium: could not cache the workspace scope");
42876
+ }
42877
+ return scope;
42878
+ }
42879
+ async function resolveSurfaceScope(join43) {
42880
+ const cached = cachedSurfaceScope(join43);
42881
+ if (cached)
42882
+ return cached;
42883
+ try {
42884
+ const workspaceId = await peerWorkspaceIdForCell(join43.cellId);
42885
+ if (!workspaceId)
42886
+ return null;
42887
+ return cacheSurfaceScope(join43, workspaceId);
42888
+ } catch (err2) {
42889
+ logger.warn({ err: err2 }, "otium: workspace scope unresolved (will retry on the next contact)");
42890
+ return null;
42891
+ }
42892
+ }
42893
+ function surfaceScopeForCell(cellId) {
42894
+ const join43 = attachedOtiumCells().find((candidate) => candidate.cellId === cellId);
42895
+ return join43 ? cachedSurfaceScope(join43) : null;
42896
+ }
42897
+ function unscopedRoomsAddressable() {
42898
+ return attachedOtiumCells().length < 2;
42899
+ }
42900
+ var init_workspace_scope = __esm(async () => {
42901
+ await init_src();
42902
+ await init_central();
42903
+ });
42904
+
42531
42905
  // ../../adapters/otium/src/session-bridge.ts
42532
42906
  function prunePendingRemoteAsks(now = Date.now()) {
42533
42907
  pruneRemoteAsks(now - PENDING_ASK_TTL_MS2);
42534
42908
  }
42535
42909
  async function postPeer(node, path, body) {
42536
42910
  try {
42537
- const token = await mintPeerToken(node.cellId);
42911
+ const token = await mintPeerToken(node);
42538
42912
  const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}${path}`, {
42539
42913
  method: "POST",
42540
42914
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -42571,7 +42945,7 @@ async function postPeerIdempotent(node, path, body) {
42571
42945
  }
42572
42946
  async function peerSupportsRemoteAsk(node) {
42573
42947
  try {
42574
- const token = await mintPeerToken(node.cellId);
42948
+ const token = await mintPeerToken(node);
42575
42949
  const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}/api/v1/peer/capabilities`, {
42576
42950
  headers: { authorization: `Bearer ${token}` },
42577
42951
  signal: AbortSignal.timeout(PEER_TIMEOUT_MS)
@@ -42582,26 +42956,50 @@ async function peerSupportsRemoteAsk(node) {
42582
42956
  return false;
42583
42957
  }
42584
42958
  }
42585
- async function findNode(nodeName) {
42586
- const find = (nodes) => nodes.find((node) => node.nodeName === nodeName) ?? null;
42587
- return find(await listPeerNodes()) ?? find(await listPeerNodes({ fresh: true }));
42959
+ async function findNode(nodeName, fromTopicId) {
42960
+ const callerScope = fromTopicId ? getTopic(fromTopicId)?.surfaceScope ?? null : null;
42961
+ const select = (nodes) => {
42962
+ let candidates = nodes.filter((node) => node.nodeName === nodeName);
42963
+ if (callerScope) {
42964
+ candidates = candidates.filter((node) => surfaceScopeForCell(node.viaCellId) === callerScope);
42965
+ }
42966
+ if (candidates.length === 0)
42967
+ return null;
42968
+ if (candidates.length > 1) {
42969
+ return {
42970
+ ok: false,
42971
+ error: `node "${nodeName}" is ambiguous across attached workspaces`
42972
+ };
42973
+ }
42974
+ return { ok: true, node: candidates[0] };
42975
+ };
42976
+ const cached = select(await listPeerNodes());
42977
+ if (cached)
42978
+ return cached;
42979
+ return select(await listPeerNodes({ fresh: true })) ?? {
42980
+ ok: false,
42981
+ error: `unknown remote node "${nodeName}"`
42982
+ };
42588
42983
  }
42589
- async function originLabel(args) {
42590
- const self = await selfPeerNode();
42984
+ async function originLabel(args, peer) {
42985
+ const self = await selfPeerNodeForCell(peer.viaCellId).catch(() => null);
42591
42986
  const local = args.fromTitle?.trim() || args.fromKey?.trim() || "peer";
42592
42987
  return self?.nodeName ? `${self.nodeName}/${local}` : local;
42593
42988
  }
42594
42989
  async function forward(args) {
42595
- const node = await findNode(args.toNode).catch(() => null);
42596
- if (!node || node.self)
42990
+ const lookup = await findNode(args.toNode, args.fromTopicId).catch(() => ({ ok: false, error: `unknown remote node "${args.toNode}"` }));
42991
+ if (!lookup.ok)
42992
+ return lookup;
42993
+ const node = lookup.node;
42994
+ if (node.self)
42597
42995
  return { ok: false, error: `unknown remote node "${args.toNode}"` };
42598
- const self = await selfPeerNode().catch(() => null);
42996
+ const self = await selfPeerNodeForCell(node.viaCellId).catch(() => null);
42599
42997
  if (!self)
42600
42998
  return { ok: false, error: "local peer node is not attached" };
42601
42999
  if (!self.isPrimary && !node.isPrimary) {
42602
43000
  return { ok: false, error: "worker peer calls must target the primary hub" };
42603
43001
  }
42604
- const fromLabel = await originLabel(args);
43002
+ const fromLabel = await originLabel(args, node);
42605
43003
  const requestId = args.requestId;
42606
43004
  if (args.action === "ask") {
42607
43005
  if (!requestId || !args.fromTopicId || !args.fromKey || !args.message) {
@@ -42671,14 +43069,20 @@ async function forward(args) {
42671
43069
  ...args.sourceQueryId ? { sourceQueryId: args.sourceQueryId } : {}
42672
43070
  });
42673
43071
  }
42674
- async function sessions(userId, sourceQueryId) {
43072
+ async function sessions(userId, sourceQueryId, fromTopicId) {
42675
43073
  const nodes = await listPeerNodes().catch(() => []);
42676
- const self = nodes.find((node) => node.self);
42677
- if (!self)
43074
+ const selves = new Map(nodes.filter((node) => node.self).map((node) => [node.viaCellId, node]));
43075
+ if (selves.size === 0)
42678
43076
  return { ok: false, nodes: [] };
43077
+ const callerScope = fromTopicId ? getTopic(fromTopicId)?.surfaceScope ?? null : null;
42679
43078
  return {
42680
43079
  ok: true,
42681
- nodes: await Promise.all(nodes.filter((node) => !node.self && node.nodeName && (self.isPrimary || node.isPrimary)).map(async (node) => {
43080
+ nodes: await Promise.all(nodes.filter((node) => {
43081
+ const self = selves.get(node.viaCellId);
43082
+ if (callerScope && surfaceScopeForCell(node.viaCellId) !== callerScope)
43083
+ return false;
43084
+ return !node.self && node.nodeName && self && (self.isPrimary || node.isPrimary);
43085
+ }).map(async (node) => {
42682
43086
  const result = await postPeer(node, "/api/v1/peer/sessions", {
42683
43087
  v: PEER_PROTOCOL_VERSION,
42684
43088
  userId,
@@ -42716,7 +43120,7 @@ function flushPeerReplyOutbox() {
42716
43120
  toTopic: "",
42717
43121
  userId: pending2.user_id,
42718
43122
  fromTitle: pending2.source_title
42719
- });
43123
+ }, node);
42720
43124
  const result = await postPeer(node, "/api/v1/peer/reply", {
42721
43125
  v: PEER_PROTOCOL_VERSION,
42722
43126
  requestId: pending2.request_id,
@@ -42799,6 +43203,7 @@ var init_session_bridge = __esm(async () => {
42799
43203
  await init_central();
42800
43204
  init_protocol();
42801
43205
  await init_store2();
43206
+ await init_workspace_scope();
42802
43207
  PENDING_ASK_TTL_MS2 = 15 * 60 * 1000;
42803
43208
  otiumPeerSessionBridge = { forward, sessions, reply };
42804
43209
  });
@@ -42886,7 +43291,7 @@ function startPeerSessionBridgeIpc(bridge) {
42886
43291
  return Response.json(await bridge.forward(payload.args));
42887
43292
  }
42888
43293
  if (payload.action === "sessions") {
42889
- return Response.json(await bridge.sessions(payload.userId, payload.sourceQueryId));
43294
+ return Response.json(await bridge.sessions(payload.userId, payload.sourceQueryId, payload.fromTopicId));
42890
43295
  }
42891
43296
  if (payload.action === "reply") {
42892
43297
  return Response.json(await bridge.reply(payload.route, payload.sourceTitle, payload.replyText, payload.kind));
@@ -43503,14 +43908,14 @@ import {
43503
43908
  existsSync as existsSync38,
43504
43909
  fsyncSync as fsyncSync3,
43505
43910
  linkSync as linkSync2,
43506
- mkdirSync as mkdirSync34,
43911
+ mkdirSync as mkdirSync35,
43507
43912
  openSync as openSync6,
43508
- readFileSync as readFileSync29,
43913
+ readFileSync as readFileSync30,
43509
43914
  renameSync as renameSync17,
43510
43915
  unlinkSync as unlinkSync24,
43511
- writeFileSync as writeFileSync24
43916
+ writeFileSync as writeFileSync26
43512
43917
  } from "fs";
43513
- import { dirname as dirname22, resolve as resolve25 } from "path";
43918
+ import { dirname as dirname23, resolve as resolve26 } from "path";
43514
43919
  function parseEnrollmentInvite(code) {
43515
43920
  let parsed;
43516
43921
  try {
@@ -43531,14 +43936,14 @@ function parseEnrollmentInvite(code) {
43531
43936
  return { v: 2, central, token };
43532
43937
  }
43533
43938
  function pendingEnrollmentPath() {
43534
- return resolve25(DATA_DIR, "otium-enrollment-pending.json");
43939
+ return resolve26(DATA_DIR, "otium-enrollment-pending.json");
43535
43940
  }
43536
43941
  function isEnrollmentPending(invite) {
43537
43942
  const path = pendingEnrollmentPath();
43538
43943
  if (!existsSync38(path))
43539
43944
  return false;
43540
43945
  try {
43541
- const saved = JSON.parse(readFileSync29(path, "utf8"));
43946
+ const saved = JSON.parse(readFileSync30(path, "utf8"));
43542
43947
  return saved.central === invite.central && saved.token === invite.token;
43543
43948
  } catch {
43544
43949
  return false;
@@ -43547,7 +43952,7 @@ function isEnrollmentPending(invite) {
43547
43952
  function loadOrCreatePending(invite, nodeName) {
43548
43953
  const path = pendingEnrollmentPath();
43549
43954
  if (existsSync38(path)) {
43550
- const saved = JSON.parse(readFileSync29(path, "utf8"));
43955
+ const saved = JSON.parse(readFileSync30(path, "utf8"));
43551
43956
  if (saved.central === invite.central && saved.token === invite.token)
43552
43957
  return saved;
43553
43958
  throw new Error(`another Otium enrollment is pending at ${path}`);
@@ -43560,12 +43965,12 @@ function loadOrCreatePending(invite, nodeName) {
43560
43965
  publicKey: pair.publicKey.export({ format: "der", type: "spki" }).toString("base64url"),
43561
43966
  ...nodeName ? { nodeName } : {}
43562
43967
  };
43563
- mkdirSync34(dirname22(path), { recursive: true });
43564
- const temporaryPath = resolve25(dirname22(path), `.otium-enrollment-pending.json.${process.pid}.${randomUUID31()}.tmp`);
43968
+ mkdirSync35(dirname23(path), { recursive: true });
43969
+ const temporaryPath = resolve26(dirname23(path), `.otium-enrollment-pending.json.${process.pid}.${randomUUID31()}.tmp`);
43565
43970
  let fd;
43566
43971
  try {
43567
43972
  fd = openSync6(temporaryPath, "wx", 384);
43568
- writeFileSync24(fd, `${JSON.stringify(pending2, null, 2)}
43973
+ writeFileSync26(fd, `${JSON.stringify(pending2, null, 2)}
43569
43974
  `, "utf8");
43570
43975
  fsyncSync3(fd);
43571
43976
  closeSync6(fd);
@@ -43573,7 +43978,7 @@ function loadOrCreatePending(invite, nodeName) {
43573
43978
  linkSync2(temporaryPath, path);
43574
43979
  unlinkSync24(temporaryPath);
43575
43980
  chmodSync8(path, 384);
43576
- const directoryFd = openSync6(dirname22(path), "r");
43981
+ const directoryFd = openSync6(dirname23(path), "r");
43577
43982
  try {
43578
43983
  fsyncSync3(directoryFd);
43579
43984
  } finally {
@@ -43585,7 +43990,7 @@ function loadOrCreatePending(invite, nodeName) {
43585
43990
  if (existsSync38(temporaryPath))
43586
43991
  unlinkSync24(temporaryPath);
43587
43992
  if (error2.code === "EEXIST" && existsSync38(path)) {
43588
- const saved = JSON.parse(readFileSync29(path, "utf8"));
43993
+ const saved = JSON.parse(readFileSync30(path, "utf8"));
43589
43994
  if (saved.central === invite.central && saved.token === invite.token)
43590
43995
  return saved;
43591
43996
  }
@@ -43595,12 +44000,12 @@ function loadOrCreatePending(invite, nodeName) {
43595
44000
  }
43596
44001
  function replacePendingEnrollment(pending2) {
43597
44002
  const path = pendingEnrollmentPath();
43598
- const directory = dirname22(path);
43599
- const temporaryPath = resolve25(directory, `.otium-enrollment-pending.json.${process.pid}.${randomUUID31()}.tmp`);
44003
+ const directory = dirname23(path);
44004
+ const temporaryPath = resolve26(directory, `.otium-enrollment-pending.json.${process.pid}.${randomUUID31()}.tmp`);
43600
44005
  let fd;
43601
44006
  try {
43602
44007
  fd = openSync6(temporaryPath, "wx", 384);
43603
- writeFileSync24(fd, `${JSON.stringify(pending2, null, 2)}
44008
+ writeFileSync26(fd, `${JSON.stringify(pending2, null, 2)}
43604
44009
  `, "utf8");
43605
44010
  fsyncSync3(fd);
43606
44011
  closeSync6(fd);
@@ -43622,7 +44027,7 @@ function replacePendingEnrollment(pending2) {
43622
44027
  }
43623
44028
  function recordClaimedCredential(pending2, join43) {
43624
44029
  withJoinCredentialLock(() => {
43625
- const current3 = JSON.parse(readFileSync29(pendingEnrollmentPath(), "utf8"));
44030
+ const current3 = JSON.parse(readFileSync30(pendingEnrollmentPath(), "utf8"));
43626
44031
  if (current3.central !== pending2.central || current3.token !== pending2.token || current3.idempotencyKey !== pending2.idempotencyKey || current3.publicKey !== pending2.publicKey) {
43627
44032
  throw new Error("pending Otium enrollment changed while its claim was in flight");
43628
44033
  }
@@ -43701,7 +44106,7 @@ async function claimEnrollment(invite, nodeName) {
43701
44106
  function commitEnrollment(join43, options = {}) {
43702
44107
  return withJoinCredentialLock(() => {
43703
44108
  const pendingPath = pendingEnrollmentPath();
43704
- const pending2 = existsSync38(pendingPath) ? JSON.parse(readFileSync29(pendingPath, "utf8")) : null;
44109
+ const pending2 = existsSync38(pendingPath) ? JSON.parse(readFileSync30(pendingPath, "utf8")) : null;
43705
44110
  const digest = joinCredentialDigest(join43);
43706
44111
  if (pending2 && (!pending2.claimed || pending2.claimed.digest !== digest || pending2.claimed.cellId !== join43.cellId)) {
43707
44112
  throw new Error(`pending Otium enrollment at ${pendingPath} does not match these credentials`);
@@ -43713,7 +44118,7 @@ function commitEnrollment(join43, options = {}) {
43713
44118
  if (!pending2)
43714
44119
  return path;
43715
44120
  unlinkSync24(pendingPath);
43716
- const directoryFd = openSync6(dirname22(pendingPath), "r");
44121
+ const directoryFd = openSync6(dirname23(pendingPath), "r");
43717
44122
  try {
43718
44123
  fsyncSync3(directoryFd);
43719
44124
  } finally {
@@ -43752,6 +44157,8 @@ async function forwardGatewayRequest(req, options) {
43752
44157
  target.search = url.search;
43753
44158
  const headers = new Headers(req.headers);
43754
44159
  headers.set("authorization", `Bearer ${NODE_CONTROL_TOKEN}`);
44160
+ headers.set(SURFACE_SCOPE_HEADER, options.surfaceScope ?? "");
44161
+ headers.set(SURFACE_SCOPE_STRICT_HEADER, options.strictScope ? "1" : "0");
43755
44162
  headers.delete("transfer-encoding");
43756
44163
  headers.delete("content-length");
43757
44164
  headers.delete("host");
@@ -43766,7 +44173,7 @@ async function forwardGatewayRequest(req, options) {
43766
44173
  signal: req.signal
43767
44174
  }));
43768
44175
  }
43769
- var RUNTIME_CONTRACT_PATH = "/api/v1/control/runtime/v1", OTIUM_GATEWAY_FORWARD_PREFIX = "/api/v1/peer/runtime";
44176
+ var RUNTIME_CONTRACT_PATH = "/api/v1/control/runtime/v1", SURFACE_SCOPE_HEADER = "x-negotium-surface-scope", SURFACE_SCOPE_STRICT_HEADER = "x-negotium-surface-scope-strict", OTIUM_GATEWAY_FORWARD_PREFIX = "/api/v1/peer/runtime";
43770
44177
  var init_gateway_forward = __esm(async () => {
43771
44178
  await init_src();
43772
44179
  });
@@ -43799,7 +44206,7 @@ function checkProtocol(body) {
43799
44206
  return null;
43800
44207
  }
43801
44208
  async function requirePeer(req) {
43802
- if (!otiumCentralConfig()) {
44209
+ if (!isOtiumCentralConfigured()) {
43803
44210
  return { ok: false, response: jsonError2("multi-node is disabled", 403) };
43804
44211
  }
43805
44212
  const token = bearer(req);
@@ -43813,8 +44220,22 @@ async function requirePeer(req) {
43813
44220
  function requirePrimaryOrigin(peer) {
43814
44221
  return peer.verified.fromIsPrimary ? null : jsonError2("only the workspace hub may call this endpoint", 403);
43815
44222
  }
43816
- function peerAddressable(topic) {
43817
- return topic.surface === "otium";
44223
+ function peerTopicByName(toTopic, userId, peer) {
44224
+ const surfaceScope = surfaceScopeForCell(peer.verified.viaCellId);
44225
+ const scoped = getTopicByNameForUser(toTopic, userId, { surface: "otium", surfaceScope });
44226
+ if (scoped)
44227
+ return scoped;
44228
+ if (!unscopedRoomsAddressable())
44229
+ return null;
44230
+ return getTopicByNameForUser(toTopic, userId, { surface: "otium", surfaceScope: null });
44231
+ }
44232
+ function peerAddressable(topic, peer) {
44233
+ if (topic.surface !== "otium")
44234
+ return false;
44235
+ const roomScope = topic.surfaceScope ?? null;
44236
+ if (!roomScope)
44237
+ return unscopedRoomsAddressable();
44238
+ return roomScope === surfaceScopeForCell(peer.verified.viaCellId);
43818
44239
  }
43819
44240
  function localCapabilities() {
43820
44241
  const agents = SUPPORTED_AGENTS.map((kind) => {
@@ -43884,8 +44305,8 @@ async function handleAbort(req) {
43884
44305
  const toTopic = str(body, "toTopic");
43885
44306
  if (!userId || !toTopic)
43886
44307
  return jsonError2("userId and toTopic are required", 400);
43887
- const topic = getTopicByNameForUser(toTopic, userId);
43888
- if (!topic || !peerAddressable(topic)) {
44308
+ const topic = peerTopicByName(toTopic, userId, peer);
44309
+ if (!topic || !peerAddressable(topic, peer)) {
43889
44310
  return jsonError2(`shared topic "${toTopic}" not found on this node`, 404);
43890
44311
  }
43891
44312
  appendJsonlEntry(sessionInboxPath(userId, topic.id), {
@@ -43926,8 +44347,8 @@ async function handleTell(req) {
43926
44347
  if (depth > MAX_TELL_DEPTH) {
43927
44348
  return jsonError2(`tell depth limit exceeded (max ${MAX_TELL_DEPTH})`, 400);
43928
44349
  }
43929
- const topic = getTopicByNameForUser(toTopic, userId);
43930
- if (!topic || !peerAddressable(topic)) {
44350
+ const topic = peerTopicByName(toTopic, userId, peer);
44351
+ if (!topic || !peerAddressable(topic, peer)) {
43931
44352
  return jsonError2(`shared topic "${toTopic}" not found on this node`, 404);
43932
44353
  }
43933
44354
  const claim = claimInboundPeerMessage({
@@ -43984,7 +44405,7 @@ async function handleSessions(req) {
43984
44405
  const userId = str(body, "userId");
43985
44406
  if (!userId)
43986
44407
  return jsonError2("userId is required", 400);
43987
- const topics = listTopics({ surface: "otium" }).filter((topic) => topic.kind !== "manager" && !topic.isSubagent && peerAddressable(topic) && topic.participants.some((p) => p.userId === userId));
44408
+ const topics = listTopics({ surface: "otium" }).filter((topic) => topic.kind !== "manager" && !topic.isSubagent && peerAddressable(topic, peer) && topic.participants.some((p) => p.userId === userId));
43988
44409
  const titleCounts = new Map;
43989
44410
  for (const topic of topics) {
43990
44411
  const normalized = topic.title.toLowerCase();
@@ -44032,8 +44453,8 @@ async function handleAsk(req) {
44032
44453
  if (!Number.isInteger(fromDepth) || fromDepth < 0) {
44033
44454
  return jsonError2("fromDepth must be a non-negative integer", 400);
44034
44455
  }
44035
- const topic = getTopicByNameForUser(toTopic, userId);
44036
- if (!topic || !peerAddressable(topic)) {
44456
+ const topic = peerTopicByName(toTopic, userId, peer);
44457
+ if (!topic || !peerAddressable(topic, peer)) {
44037
44458
  return jsonError2(`shared topic "${toTopic}" not found on this node`, 404);
44038
44459
  }
44039
44460
  if (!topic.agent)
@@ -44153,7 +44574,7 @@ async function handleOtiumPeerRequest(req) {
44153
44574
  const url = new URL(req.url);
44154
44575
  const path = url.pathname;
44155
44576
  if (path === "/ready" && req.method === "GET") {
44156
- if (!otiumCentralConfig())
44577
+ if (!isOtiumCentralConfigured())
44157
44578
  return null;
44158
44579
  return Response.json({ ok: true });
44159
44580
  }
@@ -44172,7 +44593,9 @@ async function handleOtiumPeerRequest(req) {
44172
44593
  return jsonError2("canonical Negotium node is unavailable", 503);
44173
44594
  }
44174
44595
  return forwardGatewayRequest(req, {
44175
- nodeOrigin: `http://127.0.0.1:${node.info.port}`
44596
+ nodeOrigin: `http://127.0.0.1:${node.info.port}`,
44597
+ surfaceScope: surfaceScopeForCell(peer.verified.viaCellId),
44598
+ strictScope: !unscopedRoomsAddressable()
44176
44599
  });
44177
44600
  }
44178
44601
  if (req.method === "GET") {
@@ -44217,41 +44640,89 @@ var init_peer_server = __esm(async () => {
44217
44640
  init_protocol();
44218
44641
  await init_session_bridge();
44219
44642
  await init_store2();
44643
+ await init_workspace_scope();
44220
44644
  RUNTIME_VERSION = NEGOTIUM_VERSION;
44221
44645
  });
44222
44646
 
44223
44647
  // ../../adapters/otium/src/index.ts
44648
+ function acquireGlobalOtiumServices() {
44649
+ if (globalServices) {
44650
+ globalServices.refs += 1;
44651
+ } else {
44652
+ const unregisterRuntimeBridge = registerPeerRuntimeBridge(otiumPeerRuntimeBridge);
44653
+ const unregisterSessionBridge = registerPeerSessionBridge(otiumPeerSessionBridge);
44654
+ const sessionBridgeIpc = startPeerSessionBridgeIpc(otiumPeerSessionBridge);
44655
+ const canonicalMcpBridge = startCanonicalMcpBridge();
44656
+ const stopPeerReplyOutbox = startPeerReplyOutboxWorker();
44657
+ const uninstallFileHooks = installPeerFileHooks();
44658
+ const unsubscribeTopicCleanup = runtimeBus().subscribe((event) => {
44659
+ if (event.type !== "topic-deleted")
44660
+ return;
44661
+ const removed = cleanupPeerStateForLocalTopic(event.topicId);
44662
+ if (removed.inboxRequests + removed.remoteAsks > 0) {
44663
+ logger.info({ topicId: event.topicId, ...removed }, "otium: removed peer state for deleted local topic");
44664
+ }
44665
+ });
44666
+ failInterruptedRemoteAskCallbacks().then((failedAsks) => {
44667
+ if (failedAsks > 0) {
44668
+ logger.warn({ failedAsks }, "otium: failed remote asks interrupted by previous process");
44669
+ }
44670
+ });
44671
+ globalServices = {
44672
+ refs: 1,
44673
+ stop: () => {
44674
+ unsubscribeTopicCleanup();
44675
+ unregisterRuntimeBridge();
44676
+ unregisterSessionBridge();
44677
+ sessionBridgeIpc.stop();
44678
+ canonicalMcpBridge.stop();
44679
+ stopPeerReplyOutbox();
44680
+ uninstallFileHooks();
44681
+ }
44682
+ };
44683
+ }
44684
+ let released = false;
44685
+ return () => {
44686
+ if (released || !globalServices)
44687
+ return;
44688
+ released = true;
44689
+ globalServices.refs -= 1;
44690
+ if (globalServices.refs > 0)
44691
+ return;
44692
+ globalServices.stop();
44693
+ globalServices = null;
44694
+ };
44695
+ }
44696
+ function refreshDefaultSurfaceScope() {
44697
+ const scopes = [...mountedScopes.values()];
44698
+ setDefaultSurfaceScope(scopes.length === 1 ? scopes[0] ?? null : null);
44699
+ setSurfaceScopeRequired(scopes.length > 1);
44700
+ }
44224
44701
  function startOtiumNodeRuntime(options) {
44225
44702
  const { join: join43 } = options;
44226
- configureOtiumCentral(join43);
44227
- const unregisterRuntimeBridge = registerPeerRuntimeBridge(otiumPeerRuntimeBridge);
44228
- const unregisterSessionBridge = registerPeerSessionBridge(otiumPeerSessionBridge);
44229
- const sessionBridgeIpc = startPeerSessionBridgeIpc(otiumPeerSessionBridge);
44230
- const canonicalMcpBridge = startCanonicalMcpBridge();
44231
- const stopPeerReplyOutbox = startPeerReplyOutboxWorker();
44232
- failInterruptedRemoteAskCallbacks().then((failedAsks) => {
44233
- if (failedAsks > 0) {
44234
- logger.warn({ failedAsks }, "otium: failed remote asks interrupted by previous process");
44235
- }
44236
- });
44237
- const uninstallFileHooks = installPeerFileHooks();
44238
- const unsubscribeTopicCleanup = runtimeBus().subscribe((event) => {
44239
- if (event.type !== "topic-deleted")
44240
- return;
44241
- const removed = cleanupPeerStateForLocalTopic(event.topicId);
44242
- if (removed.inboxRequests + removed.remoteAsks > 0) {
44243
- logger.info({ topicId: event.topicId, ...removed }, "otium: removed peer state for deleted local topic");
44244
- }
44245
- });
44703
+ attachOtiumCentralCell(join43);
44704
+ const releaseGlobals = acquireGlobalOtiumServices();
44246
44705
  let stopped = false;
44247
44706
  logger.info({ central: join43.central, cellId: join43.cellId }, "otium: worker mode enabled");
44248
- selfPeerNode().then((self) => {
44707
+ mountedScopes.set(join43.cellId, cachedSurfaceScope(join43));
44708
+ refreshDefaultSurfaceScope();
44709
+ selfPeerNodeForCell(join43.cellId).then((self) => {
44249
44710
  if (self) {
44250
44711
  logger.info({ nodeName: self.nodeName, baseUrl: self.baseUrl }, "otium: attached to workspace");
44251
44712
  }
44252
44713
  }).catch((err2) => {
44253
44714
  logger.warn({ err: err2 }, "otium: self check against central failed (will retry per request)");
44254
44715
  });
44716
+ resolveSurfaceScope(join43).then((scope) => {
44717
+ if (!scope || stopped)
44718
+ return;
44719
+ mountedScopes.set(join43.cellId, scope);
44720
+ refreshDefaultSurfaceScope();
44721
+ if (mountedScopes.size === 1)
44722
+ stampUnscopedOtiumTopics(scope);
44723
+ }).catch((err2) => {
44724
+ logger.warn({ err: err2 }, "otium: workspace scope resolution failed");
44725
+ });
44255
44726
  return {
44256
44727
  name: "otium",
44257
44728
  join: join43,
@@ -44259,14 +44730,10 @@ function startOtiumNodeRuntime(options) {
44259
44730
  if (stopped)
44260
44731
  return;
44261
44732
  stopped = true;
44262
- unsubscribeTopicCleanup();
44263
- unregisterRuntimeBridge();
44264
- unregisterSessionBridge();
44265
- sessionBridgeIpc.stop();
44266
- canonicalMcpBridge.stop();
44267
- stopPeerReplyOutbox();
44268
- uninstallFileHooks();
44269
- configureOtiumCentral(null);
44733
+ mountedScopes.delete(join43.cellId);
44734
+ refreshDefaultSurfaceScope();
44735
+ detachOtiumCentralCell(join43.cellId);
44736
+ releaseGlobals();
44270
44737
  }
44271
44738
  };
44272
44739
  }
@@ -44304,7 +44771,7 @@ function startOtiumAdapter(options) {
44304
44771
  }
44305
44772
  };
44306
44773
  }
44307
- var otiumAdapter;
44774
+ var globalServices = null, mountedScopes, otiumAdapter;
44308
44775
  var init_src8 = __esm(async () => {
44309
44776
  await init_src();
44310
44777
  await init_canonical_mcp_bridge();
@@ -44316,6 +44783,7 @@ var init_src8 = __esm(async () => {
44316
44783
  init_session_bridge_ipc();
44317
44784
  await init_store2();
44318
44785
  init_tunnel_client();
44786
+ await init_workspace_scope();
44319
44787
  await init_central();
44320
44788
  await init_enrollment();
44321
44789
  await init_join();
@@ -44325,6 +44793,8 @@ var init_src8 = __esm(async () => {
44325
44793
  await init_runtime_bridge();
44326
44794
  await init_store2();
44327
44795
  init_tunnel_client();
44796
+ await init_workspace_scope();
44797
+ mountedScopes = new Map;
44328
44798
  otiumAdapter = defineNegotiumAdapter({
44329
44799
  name: "otium",
44330
44800
  capabilities: {
@@ -44344,12 +44814,59 @@ var init_src8 = __esm(async () => {
44344
44814
  // ../../adapters/otium/src/node-runtime.ts
44345
44815
  var exports_node_runtime = {};
44346
44816
  __export(exports_node_runtime, {
44817
+ reconcileOtiumWorkspaces: () => reconcileOtiumWorkspaces,
44818
+ mountedOtiumWorkspaces: () => mountedOtiumWorkspaces,
44347
44819
  mountConfiguredOtiumNodeRuntime: () => mountConfiguredOtiumNodeRuntime,
44348
44820
  handleOtiumAdapterControlRequest: () => handleOtiumAdapterControlRequest,
44821
+ detachOtiumWorkspace: () => detachOtiumWorkspace,
44822
+ attachOtiumWorkspace: () => attachOtiumWorkspace,
44349
44823
  OTIUM_ADAPTER_CONTROL_PREFIX: () => OTIUM_ADAPTER_CONTROL_PREFIX,
44350
44824
  OTIUM_ADAPTER_CONTROL_HEADER: () => OTIUM_ADAPTER_CONTROL_HEADER,
44351
44825
  MAX_PEER_REQUEST_BODY_BYTES: () => MAX_PEER_REQUEST_BODY_BYTES
44352
44826
  });
44827
+ function sameCredentials(left, right) {
44828
+ return left.central === right.central && left.relay === right.relay && left.secret === right.secret;
44829
+ }
44830
+ function attachOtiumWorkspace(join43) {
44831
+ const current3 = mounted.get(join43.cellId);
44832
+ if (current3) {
44833
+ if (sameCredentials(current3.join, join43))
44834
+ return false;
44835
+ detachOtiumWorkspace(join43.cellId);
44836
+ }
44837
+ mounted.set(join43.cellId, startOtiumNodeRuntime({ join: join43 }));
44838
+ return true;
44839
+ }
44840
+ function detachOtiumWorkspace(cellId) {
44841
+ const runtime2 = mounted.get(cellId);
44842
+ if (!runtime2)
44843
+ return false;
44844
+ mounted.delete(cellId);
44845
+ runtime2.stop();
44846
+ return true;
44847
+ }
44848
+ function mountedOtiumWorkspaces() {
44849
+ return [...mounted.values()].map((runtime2) => runtime2.join);
44850
+ }
44851
+ function reconcileOtiumWorkspaces(joins = loadJoins()) {
44852
+ const wanted = new Map(joins.map((join43) => [join43.cellId, join43]));
44853
+ const detached = [];
44854
+ for (const cellId of [...mounted.keys()]) {
44855
+ if (wanted.has(cellId))
44856
+ continue;
44857
+ detachOtiumWorkspace(cellId);
44858
+ detached.push(cellId);
44859
+ }
44860
+ const attached = [];
44861
+ for (const [cellId, join43] of wanted) {
44862
+ if (attachOtiumWorkspace(join43))
44863
+ attached.push(cellId);
44864
+ }
44865
+ if (attached.length > 0 || detached.length > 0) {
44866
+ logger.info({ attached, detached }, "otium: workspace attachments reconciled");
44867
+ }
44868
+ return { attached, detached };
44869
+ }
44353
44870
  async function handleOtiumAdapterControlRequest(req) {
44354
44871
  const url = new URL(req.url);
44355
44872
  if (!url.pathname.startsWith(`${OTIUM_ADAPTER_CONTROL_PREFIX}/`))
@@ -44358,6 +44875,25 @@ async function handleOtiumAdapterControlRequest(req) {
44358
44875
  return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
44359
44876
  }
44360
44877
  const peerPath = url.pathname.slice(OTIUM_ADAPTER_CONTROL_PREFIX.length) || "/";
44878
+ if (peerPath === OTIUM_WORKSPACES_CONTROL_PATH) {
44879
+ if (req.headers.get(OTIUM_RELAYED_HEADER)) {
44880
+ return Response.json({ ok: false, error: "not found" }, { status: 404 });
44881
+ }
44882
+ if (req.method === "GET") {
44883
+ return Response.json({
44884
+ ok: true,
44885
+ workspaces: mountedOtiumWorkspaces().map((join43) => ({
44886
+ cellId: join43.cellId,
44887
+ central: join43.central
44888
+ }))
44889
+ });
44890
+ }
44891
+ if (req.method === "POST") {
44892
+ const result = reconcileOtiumWorkspaces();
44893
+ return Response.json({ ok: true, ...result });
44894
+ }
44895
+ return Response.json({ ok: false, error: "method not allowed" }, { status: 405 });
44896
+ }
44361
44897
  const peerUrl = new URL(req.url);
44362
44898
  peerUrl.pathname = peerPath;
44363
44899
  const headers = new Headers(req.headers);
@@ -44366,29 +44902,72 @@ async function handleOtiumAdapterControlRequest(req) {
44366
44902
  return await handleOtiumPeerRequest(new Request(peerUrl.toString(), { method: req.method, headers, body, signal: req.signal })) ?? Response.json({ ok: false, error: "Otium route not found" }, { status: 404 });
44367
44903
  }
44368
44904
  function mountConfiguredOtiumNodeRuntime() {
44369
- const join43 = loadJoin();
44370
- if (!join43)
44905
+ const joins = loadJoins();
44906
+ if (joins.length === 0)
44371
44907
  return null;
44372
- const runtime2 = startOtiumNodeRuntime({ join: join43 });
44908
+ for (const join43 of joins)
44909
+ attachOtiumWorkspace(join43);
44373
44910
  registerNodeRequestHandler("otium-adapter-control", handleOtiumAdapterControlRequest);
44374
44911
  let stopped = false;
44375
44912
  return {
44376
- ...runtime2,
44913
+ name: "otium",
44914
+ join: joins[0],
44377
44915
  stop() {
44378
44916
  if (stopped)
44379
44917
  return;
44380
44918
  stopped = true;
44381
44919
  unregisterNodeRequestHandler("otium-adapter-control");
44382
- runtime2.stop();
44920
+ for (const cellId of [...mounted.keys()])
44921
+ detachOtiumWorkspace(cellId);
44383
44922
  }
44384
44923
  };
44385
44924
  }
44925
+ var mounted;
44386
44926
  var init_node_runtime = __esm(async () => {
44387
44927
  await init_src();
44388
44928
  await init_src8();
44389
44929
  await init_join();
44390
44930
  await init_peer_server();
44391
44931
  init_protocol();
44932
+ mounted = new Map;
44933
+ });
44934
+
44935
+ // ../../adapters/otium/src/workspace-control.ts
44936
+ var exports_workspace_control = {};
44937
+ __export(exports_workspace_control, {
44938
+ reconcileRunningNodeWorkspaces: () => reconcileRunningNodeWorkspaces
44939
+ });
44940
+ async function reconcileRunningNodeWorkspaces() {
44941
+ try {
44942
+ const { inspectNodeDaemon: inspectNodeDaemon2 } = await init_src5().then(() => exports_src3);
44943
+ const status = await inspectNodeDaemon2();
44944
+ if (!status.running || !status.info) {
44945
+ return { ok: false, attached: [], detached: [], error: "node is not running" };
44946
+ }
44947
+ const response = await fetch(`http://127.0.0.1:${status.info.port}${OTIUM_ADAPTER_CONTROL_PREFIX}${OTIUM_WORKSPACES_CONTROL_PATH}`, {
44948
+ method: "POST",
44949
+ headers: {
44950
+ [OTIUM_ADAPTER_CONTROL_HEADER]: NODE_CONTROL_TOKEN,
44951
+ "content-type": "application/json"
44952
+ },
44953
+ signal: AbortSignal.timeout(5000)
44954
+ });
44955
+ const body = await response.json().catch(() => null);
44956
+ if (!response.ok || !body?.ok) {
44957
+ return {
44958
+ ok: false,
44959
+ attached: [],
44960
+ detached: [],
44961
+ error: body?.error ?? `node returned ${response.status}`
44962
+ };
44963
+ }
44964
+ return { ok: true, attached: body.attached ?? [], detached: body.detached ?? [] };
44965
+ } catch (error2) {
44966
+ return { ok: false, attached: [], detached: [], error: error2.message };
44967
+ }
44968
+ }
44969
+ var init_workspace_control = __esm(async () => {
44970
+ await init_src();
44392
44971
  });
44393
44972
 
44394
44973
  // ../../adapters/otium/src/join-cli.ts
@@ -44482,6 +45061,12 @@ async function joinCommand(args) {
44482
45061
  } finally {
44483
45062
  configureOtiumCentral(null);
44484
45063
  }
45064
+ const { reconcileRunningNodeWorkspaces: reconcileRunningNodeWorkspaces2 } = await init_workspace_control().then(() => exports_workspace_control);
45065
+ const applied = await reconcileRunningNodeWorkspaces2();
45066
+ if (applied.ok && applied.attached.length > 0) {
45067
+ console.log("the running node attached this workspace; the others kept running");
45068
+ return;
45069
+ }
44485
45070
  console.log("\nnext: `negotium-otium serve` (mounts the otium peer routes automatically)");
44486
45071
  }
44487
45072
  var init_join_cli = __esm(async () => {
@@ -44496,9 +45081,15 @@ __export(exports_sidecar, {
44496
45081
  runOtiumSidecar: () => runOtiumSidecar,
44497
45082
  proxyOtiumPeerRequest: () => proxyOtiumPeerRequest
44498
45083
  });
45084
+ function isPublicPeerPath(pathname) {
45085
+ return pathname === "/ready" || pathname.startsWith("/api/v1/peer/");
45086
+ }
44499
45087
  async function proxyOtiumPeerRequest(req, dependencies = {}) {
44500
45088
  const inspectNode = dependencies.inspectNode ?? inspectNodeDaemon;
44501
45089
  const fetchRequest = dependencies.fetch ?? fetch;
45090
+ if (!isPublicPeerPath(new URL(req.url).pathname)) {
45091
+ return Response.json({ ok: false, error: "not found" }, { status: 404 });
45092
+ }
44502
45093
  const status = await inspectNode();
44503
45094
  if (!status.running || !status.info) {
44504
45095
  return Response.json({ ok: false, error: "canonical Negotium node is unavailable" }, { status: 503 });
@@ -44509,6 +45100,7 @@ async function proxyOtiumPeerRequest(req, dependencies = {}) {
44509
45100
  target.search = source.search;
44510
45101
  const headers = new Headers(req.headers);
44511
45102
  headers.set(OTIUM_ADAPTER_CONTROL_HEADER, NODE_CONTROL_TOKEN);
45103
+ headers.set(OTIUM_RELAYED_HEADER, "1");
44512
45104
  try {
44513
45105
  const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
44514
45106
  headers.delete("transfer-encoding");
@@ -44565,8 +45157,8 @@ async function runOtiumSidecar(options) {
44565
45157
  }) : null;
44566
45158
  tunnel?.start();
44567
45159
  let resolveCompleted;
44568
- const completed = new Promise((resolve26) => {
44569
- resolveCompleted = resolve26;
45160
+ const completed = new Promise((resolve27) => {
45161
+ resolveCompleted = resolve27;
44570
45162
  });
44571
45163
  onShutdown("otium-sidecar-server", 130, () => server?.stop(true));
44572
45164
  onShutdown("otium-sidecar-tunnel", 120, () => tunnel?.stop());
@@ -44692,16 +45284,28 @@ async function runOtiumCli(args = process.argv.slice(2)) {
44692
45284
  break;
44693
45285
  }
44694
45286
  case "leave": {
44695
- if (commandArgs.length > 0)
44696
- throw new Error(`usage: negotium otium ${command}`);
45287
+ const targetCellId = commandArgs[0]?.trim();
45288
+ if (commandArgs.length > 1 || targetCellId?.startsWith("-")) {
45289
+ throw new Error(`usage: negotium otium ${command} [<cell-id>]`);
45290
+ }
44697
45291
  if (process.env.OTIUM_CENTRAL_URL || process.env.OTIUM_CELL_ID || process.env.OTIUM_CELL_SECRET) {
44698
45292
  throw new Error("Otium join is configured by environment; remove OTIUM_CENTRAL_URL, OTIUM_CELL_ID, and OTIUM_CELL_SECRET to disconnect");
44699
45293
  }
44700
- const { loadJoin: loadJoin2, removeJoin: removeJoin2 } = await init_join().then(() => exports_join);
44701
- if (!loadJoin2())
45294
+ const { loadJoins: loadJoins2, removeJoin: removeJoin2 } = await init_join().then(() => exports_join);
45295
+ const joins = loadJoins2();
45296
+ if (joins.length === 0)
44702
45297
  throw new Error("not joined to an Otium workspace");
44703
- removeJoin2();
44704
- console.log("disconnected from Otium; workspace credentials removed");
45298
+ if (!targetCellId && joins.length > 1) {
45299
+ throw new Error(`this node is joined to ${joins.length} workspaces; name one to leave: ${joins.map((join43) => join43.cellId).join(", ")}`);
45300
+ }
45301
+ if (targetCellId && !joins.some((join43) => join43.cellId === targetCellId)) {
45302
+ throw new Error(`not joined as ${targetCellId}`);
45303
+ }
45304
+ removeJoin2(targetCellId);
45305
+ console.log(targetCellId ? `left Otium workspace ${targetCellId}; its credentials were removed` : "disconnected from Otium; workspace credentials removed");
45306
+ const { reconcileRunningNodeWorkspaces: reconcileRunningNodeWorkspaces2 } = await init_workspace_control().then(() => exports_workspace_control);
45307
+ const applied = await reconcileRunningNodeWorkspaces2();
45308
+ console.log(applied.ok ? "the running node has detached it; other workspaces are unaffected" : "restart the node to apply this");
44705
45309
  break;
44706
45310
  }
44707
45311
  case "serve": {
@@ -44724,8 +45328,9 @@ async function runOtiumCli(args = process.argv.slice(2)) {
44724
45328
  " serve [--port <port>] [--relay <url>]",
44725
45329
  " run peer routes and an outbound relay tunnel",
44726
45330
  "",
44727
- "Publish a topic to the workspace with /public in that topic; the hub",
44728
- "discovers it over the Runtime Gateway. /private withdraws it."
45331
+ "Rooms on the otium surface are discovered by the hub over the Runtime",
45332
+ "Gateway. There is nothing to publish or withdraw: a room's surface is",
45333
+ "fixed when it is created."
44729
45334
  ].join(`
44730
45335
  `));
44731
45336
  if (command && command !== "help" && command !== "--help")
@@ -44768,7 +45373,7 @@ __export(exports_topics, {
44768
45373
  topicsCommand: () => topicsCommand
44769
45374
  });
44770
45375
  function topicsCommand() {
44771
- const topics = getVisibleTopics();
45376
+ const topics = getVisibleTopics({ surface: "terminal" });
44772
45377
  if (topics.length === 0) {
44773
45378
  console.log("no topics yet - start `negotium` to create one in Terminal");
44774
45379
  return;
@@ -45267,6 +45872,16 @@ function renderCliHelp() {
45267
45872
  // ../cli/src/main.ts
45268
45873
  var [, , rawCommand, ...args] = process.argv;
45269
45874
  var command = normalizeCliCommand(rawCommand);
45875
+ async function runOtium(args2) {
45876
+ const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
45877
+ try {
45878
+ await runOtiumCli2(args2);
45879
+ } catch (error2) {
45880
+ process.stderr.write(`negotium otium: ${error2 instanceof Error ? error2.message : String(error2)}
45881
+ `);
45882
+ process.exitCode = 1;
45883
+ }
45884
+ }
45270
45885
  function numericOption(values, name, fallback) {
45271
45886
  const prefix = `--${name}=`;
45272
45887
  const parsed = Number.parseInt(values.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? "", 10);
@@ -45292,7 +45907,7 @@ async function runCanonicalNode(port) {
45292
45907
  });
45293
45908
  console.log(`negotium node listening on 127.0.0.1:${node.port} (ctrl-c to stop)`);
45294
45909
  await node.completed;
45295
- await new Promise((resolve26) => setImmediate(resolve26));
45910
+ await new Promise((resolve27) => setImmediate(resolve27));
45296
45911
  process.exit(0);
45297
45912
  }
45298
45913
  async function stopAdapter(name) {
@@ -45324,8 +45939,7 @@ switch (command) {
45324
45939
  }
45325
45940
  case "serve": {
45326
45941
  if (args[0] === "otium") {
45327
- const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
45328
- await runOtiumCli2(["serve", ...args.slice(1)]);
45942
+ await runOtium(["serve", ...args.slice(1)]);
45329
45943
  break;
45330
45944
  }
45331
45945
  await runCanonicalNode(numericOption(args, "port", 7777));
@@ -45409,11 +46023,10 @@ switch (command) {
45409
46023
  break;
45410
46024
  }
45411
46025
  case "otium": {
45412
- const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
45413
46026
  if (args[0] === "serve") {
45414
46027
  process.stderr.write("warning: `negotium otium serve` is deprecated; use `negotium serve otium`\n");
45415
46028
  }
45416
- await runOtiumCli2(args);
46029
+ await runOtium(args);
45417
46030
  break;
45418
46031
  }
45419
46032
  default: {
@@ -45423,4 +46036,4 @@ switch (command) {
45423
46036
  }
45424
46037
  }
45425
46038
 
45426
- //# debugId=50D62D73CF0EE2E064756E2164756E21
46039
+ //# debugId=EDB732002F3171B764756E2164756E21