negotium 0.2.27 → 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 +197 -113
  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 +1121 -506
  6. package/dist/main.js.map +43 -41
  7. package/dist/mcp-factories.js +291 -211
  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 +202 -26
  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 +138 -26
  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.27";
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(`
@@ -7833,21 +7887,23 @@ function backfillTopicSurfaces() {
7833
7887
  }
7834
7888
  function renameSurfaceTitleCollisions() {
7835
7889
  const rows = db.query("SELECT id, title, kind, surface FROM api_topics WHERE kind != 'manager' ORDER BY created_at ASC, rowid ASC").all();
7836
- const taken = new Set;
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,6 +8153,7 @@ 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);
@@ -8083,8 +8162,8 @@ function findTopicTitleConflict(title, kind, opts = {}) {
8083
8162
  }
8084
8163
  if (kind === "manager")
8085
8164
  return null;
8086
- const params = [wanted, surface, kind, GENERAL_TOPIC_ID];
8087
- let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ? AND (kind = ? OR id = ?)";
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 = ?)";
8088
8167
  if (opts.excludeTopicId) {
8089
8168
  sql += " AND id != ?";
8090
8169
  params.push(opts.excludeTopicId);
@@ -8111,14 +8190,16 @@ function getTopicByNameForUser(title, userId, opts = {}) {
8111
8190
  const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
8112
8191
  const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
8113
8192
  const requestedTitle = qualified ? qualified[2].trim() : trimmed;
8193
+ const scoped = Object.hasOwn(opts, "surfaceScope");
8114
8194
  const rows = db.query(`SELECT t.* FROM api_topics t
8115
8195
  WHERE LOWER(t.title) = LOWER(?)
8116
8196
  AND t.id != ?
8117
8197
  AND t.visibility != 'hidden'
8118
8198
  AND (? IS NULL OR t.surface = ?)
8199
+ ${scoped ? "AND t.surface_scope IS ?" : ""}
8119
8200
  AND EXISTS (
8120
8201
  SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
8121
- )`).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);
8122
8203
  const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
8123
8204
  return matches.length === 1 ? rowToDto2(matches[0]) : null;
8124
8205
  }
@@ -8206,7 +8287,7 @@ function removeParticipantFromDB(topicId, userId) {
8206
8287
  db.query("DELETE FROM topic_members WHERE topic_id = ? AND user_id = ?").run(topicId, userId);
8207
8288
  return true;
8208
8289
  }
8209
- 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";
8210
8291
  var init_api_topics = __esm(async () => {
8211
8292
  init_constants();
8212
8293
  init_logger();
@@ -11426,8 +11507,10 @@ var init_active_rooms = __esm(async () => {
11426
11507
 
11427
11508
  // ../../packages/core/src/topics/personal-general.ts
11428
11509
  import { randomUUID as randomUUID9 } from "crypto";
11429
- function ensurePersonalGeneral(userId) {
11430
- 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 });
11431
11514
  if (existing) {
11432
11515
  if (existing.description === LEGACY_PERSONAL_GENERAL_DESCRIPTION) {
11433
11516
  existing.description = PERSONAL_GENERAL_DESCRIPTION;
@@ -11451,6 +11534,8 @@ function ensurePersonalGeneral(userId) {
11451
11534
  aiMode: "always",
11452
11535
  aiMention: false,
11453
11536
  participants: [{ userId, role: "owner" }],
11537
+ surface: scope,
11538
+ surfaceScope,
11454
11539
  createdAt: now,
11455
11540
  lastMessageAt: now
11456
11541
  };
@@ -13438,7 +13523,8 @@ function mergeRuntimeUserTurnRequest(input) {
13438
13523
  const now = Date.now();
13439
13524
  return db.transaction(() => {
13440
13525
  const rows = db.query("SELECT * FROM runtime_user_turn_requests WHERE topic_id = ? ORDER BY created_at ASC, rowid ASC").all(input.topicId);
13441
- const previous = rows.map(rowToRequest);
13526
+ const thread = input.execution.threadRootId;
13527
+ const previous = rows.map(rowToRequest).filter((request) => request.execution?.threadRootId === thread);
13442
13528
  const omittedRequestIds = new Set([
13443
13529
  ...input.omitRequestIds ?? [],
13444
13530
  ...previous.filter((request) => Boolean(request.execution?.providerSessionId)).map((request) => request.requestId)
@@ -13474,7 +13560,11 @@ function mergeRuntimeUserTurnRequest(input) {
13474
13560
  execution.sessionIdSpecified = true;
13475
13561
  }
13476
13562
  const attachments = flattenUserTurnAttachments(userMessages);
13477
- 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
+ }
13478
13568
  db.query(`INSERT INTO runtime_user_turn_requests
13479
13569
  (request_id, topic_id, user_id, prompt, user_messages_json, attachments_json,
13480
13570
  allow_auto_continue, execution_json, topic_epoch, created_at,
@@ -14321,8 +14411,8 @@ function updateTopic(topicId, patch) {
14321
14411
  function isParticipant(topic, userId) {
14322
14412
  return topic.participants.some((p) => p.userId === userId);
14323
14413
  }
14324
- function nextDerivedTopicTitle(sourceTitle, kind, suffix, surface) {
14325
- 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()));
14326
14416
  let n = 1;
14327
14417
  let title = `${sourceTitle}-${suffix}-${n}`;
14328
14418
  while (visibleTitles.has(title.toLowerCase())) {
@@ -14387,8 +14477,9 @@ async function createDerivedTopicImpl(topic, sourceTopicId, userId, copyHistory,
14387
14477
  ] : [{ userId, role: "owner" }];
14388
14478
  const kind = topic.kind ?? inferTopicKind(topic);
14389
14479
  const surface = topic.surface ?? defaultTopicSurface();
14390
- const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface);
14391
- 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 });
14392
14483
  if (conflict) {
14393
14484
  logger.info({ sourceTopicId, title, kind, conflictTopicId: conflict.id }, "createDerivedTopic: title conflict");
14394
14485
  throw new TopicTitleConflictError(title);
@@ -14411,7 +14502,8 @@ async function createDerivedTopicImpl(topic, sourceTopicId, userId, copyHistory,
14411
14502
  isFork: copyHistory,
14412
14503
  ...subagent ? { isSubagent: true } : {},
14413
14504
  visibility: topic.visibility,
14414
- surface
14505
+ surface,
14506
+ surfaceScope
14415
14507
  };
14416
14508
  let sessionId;
14417
14509
  let rollbackHandle;
@@ -14519,7 +14611,10 @@ async function createDerivedTopicImpl(topic, sourceTopicId, userId, copyHistory,
14519
14611
  if (!currentSource || currentSource.kind === "manager" || !isParticipant(currentSource, userId) || subagent && isRuntimeTopicMaintenance(sourceTopicId)) {
14520
14612
  throw new TopicDeriveBusyError("Source topic changed while deriving; try again");
14521
14613
  }
14522
- const transactionalConflict = findTopicTitleConflict(title, kind, { surface });
14614
+ const transactionalConflict = findTopicTitleConflict(title, kind, {
14615
+ surface,
14616
+ surfaceScope
14617
+ });
14523
14618
  if (transactionalConflict)
14524
14619
  throw new TopicTitleConflictError(title);
14525
14620
  upsertTopic(derived);
@@ -15389,13 +15484,14 @@ async function forwardToPeer(args) {
15389
15484
  error: forwarded.configured ? "remote session bridge is configured but unavailable" : "remote nodes are not connected on this negotium node (standalone mode)"
15390
15485
  };
15391
15486
  }
15392
- async function peerSessionsForUser(userId, sourceQueryId) {
15487
+ async function peerSessionsForUser(userId, sourceQueryId, fromTopicId) {
15393
15488
  if (activeBridge)
15394
- return activeBridge.sessions(userId, sourceQueryId);
15489
+ return activeBridge.sessions(userId, sourceQueryId, fromTopicId);
15395
15490
  const sessions = await callLoopbackBridge({
15396
15491
  action: "sessions",
15397
15492
  userId,
15398
- sourceQueryId
15493
+ sourceQueryId,
15494
+ fromTopicId
15399
15495
  });
15400
15496
  if (sessions.result)
15401
15497
  return sessions.result;
@@ -16151,6 +16247,226 @@ body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--
16151
16247
  </style>`;
16152
16248
  });
16153
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
+
16154
16470
  // ../../packages/core/src/topics/lifecycle.ts
16155
16471
  var exports_lifecycle = {};
16156
16472
  __export(exports_lifecycle, {
@@ -16200,6 +16516,7 @@ async function cleanupParticipantResources(topic, userIds, sessionId, cwd, purge
16200
16516
  cleanupSessionInboxFiles(participantUserId, topic.id, topic.title);
16201
16517
  clearQueryState(participantUserId, topic.id, topic.title);
16202
16518
  clearQueryUsageAlert(participantUserId, topic.id);
16519
+ deleteTopicStats(participantUserId, topic.id);
16203
16520
  deletePendingAsksForTopic({ userId: participantUserId, topicName: topic.title });
16204
16521
  }
16205
16522
  return true;
@@ -16354,6 +16671,7 @@ var init_lifecycle = __esm(async () => {
16354
16671
  await init_runtime_turn_requests();
16355
16672
  await init_self_schedules();
16356
16673
  await init_session_asks();
16674
+ await init_token_stats();
16357
16675
  await init_topic_archive();
16358
16676
  await init_topic_archive_state();
16359
16677
  TopicArchiveRequiredError = class TopicArchiveRequiredError extends Error {
@@ -16388,8 +16706,8 @@ var init_lifecycle = __esm(async () => {
16388
16706
 
16389
16707
  // ../../packages/core/src/runtime/attachments.ts
16390
16708
  import { randomUUID as randomUUID14 } from "crypto";
16391
- import { copyFileSync as copyFileSync2, mkdirSync as mkdirSync16, writeFileSync as writeFileSync14 } from "fs";
16392
- 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";
16393
16711
  function workspaceCwdFor(topicId) {
16394
16712
  return resolveTopicWorkspaceDir(topicId);
16395
16713
  }
@@ -16402,7 +16720,7 @@ function materializePromptAttachments(topicId, queryId, attachmentIds) {
16402
16720
  if (!attachmentIds?.length)
16403
16721
  return [];
16404
16722
  const out = [];
16405
- const destDir = join25(workspaceCwdFor(topicId), "attachments", queryId);
16723
+ const destDir = join26(workspaceCwdFor(topicId), "attachments", queryId);
16406
16724
  for (const rawId of attachmentIds) {
16407
16725
  if (typeof rawId !== "string")
16408
16726
  continue;
@@ -16416,10 +16734,10 @@ function materializePromptAttachments(topicId, queryId, attachmentIds) {
16416
16734
  continue;
16417
16735
  }
16418
16736
  try {
16419
- mkdirSync16(destDir, { recursive: true });
16737
+ mkdirSync17(destDir, { recursive: true });
16420
16738
  const index = String(out.length + 1).padStart(2, "0");
16421
16739
  const safeName = safeAttachmentFilename(attachment.filename, fileId);
16422
- const destPath = join25(destDir, `${index}-${fileId.slice(0, 8)}-${safeName}`);
16740
+ const destPath = join26(destDir, `${index}-${fileId.slice(0, 8)}-${safeName}`);
16423
16741
  copyFileSync2(sourcePath, destPath);
16424
16742
  out.push({
16425
16743
  id: attachment.id,
@@ -16449,14 +16767,14 @@ function promptWithAttachments(prompt, attachments) {
16449
16767
  return composeAttachmentPrompt(prompt, attachments.map(({ filename, path }) => attachmentPromptLine(filename, path)));
16450
16768
  }
16451
16769
  function ingestAttachment(args) {
16452
- const destDir = join25(UPLOADS_DIR, args.topicId);
16453
- mkdirSync16(destDir, { recursive: true });
16770
+ const destDir = join26(UPLOADS_DIR, args.topicId);
16771
+ mkdirSync17(destDir, { recursive: true });
16454
16772
  const safeName = safeAttachmentFilename(args.filename, "upload");
16455
- const destPath = join25(destDir, `${Date.now()}-${randomUUID14().slice(0, 8)}-${safeName}`);
16773
+ const destPath = join26(destDir, `${Date.now()}-${randomUUID14().slice(0, 8)}-${safeName}`);
16456
16774
  if (args.sourcePath !== undefined) {
16457
16775
  copyFileSync2(args.sourcePath, destPath);
16458
16776
  } else if (args.bytes !== undefined) {
16459
- writeFileSync14(destPath, args.bytes);
16777
+ writeFileSync15(destPath, args.bytes);
16460
16778
  } else {
16461
16779
  throw new Error("ingestAttachment: provide sourcePath or bytes");
16462
16780
  }
@@ -17052,204 +17370,6 @@ var init_visuals = __esm(async () => {
17052
17370
  init_visual_html();
17053
17371
  });
17054
17372
 
17055
- // ../../packages/core/src/storage/token-stats.ts
17056
- var exports_token_stats = {};
17057
- __export(exports_token_stats, {
17058
- tokenStatsFileId: () => tokenStatsFileId,
17059
- recordUsage: () => recordUsage,
17060
- getTopicStats: () => getTopicStats,
17061
- getStats: () => getStats,
17062
- calcCost: () => calcCost
17063
- });
17064
- import { createHash as createHash7 } from "crypto";
17065
- import { mkdirSync as mkdirSync17 } from "fs";
17066
- import { join as join26 } from "path";
17067
- function emptyBucket() {
17068
- return {
17069
- inputTokens: 0,
17070
- outputTokens: 0,
17071
- cacheCreationInputTokens: 0,
17072
- cacheReadInputTokens: 0,
17073
- queries: 0,
17074
- estimatedCostUsd: 0
17075
- };
17076
- }
17077
- function tokenStatsFileId(userId) {
17078
- const rawUserId = String(userId);
17079
- return /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,255}$/.test(rawUserId) && !rawUserId.includes("..") ? rawUserId : `sha256-${createHash7("sha256").update(rawUserId).digest("hex")}`;
17080
- }
17081
- function queriesPath(userId) {
17082
- const fileId = tokenStatsFileId(userId);
17083
- const logDir = resolveStorageLogDir();
17084
- mkdirSync17(logDir, { recursive: true });
17085
- return join26(logDir, `token-queries-${fileId}.jsonl`);
17086
- }
17087
- function loadRecords(userId) {
17088
- try {
17089
- return readJsonlLines(queriesPath(userId)).flatMap((line) => {
17090
- try {
17091
- return [JSON.parse(line)];
17092
- } catch {
17093
- return [];
17094
- }
17095
- });
17096
- } catch {
17097
- return [];
17098
- }
17099
- }
17100
- function calcCost(b) {
17101
- return b.estimatedCostUsd;
17102
- }
17103
- function estimateUsageCost(agent, model, usage) {
17104
- const prices = TOKEN_PRICES[`${agent}:${model}`];
17105
- if (!prices)
17106
- return 0;
17107
- return (usage.inputTokens * prices.input + usage.outputTokens * prices.output + (agent === "claude" ? usage.cacheCreationInputTokens * (prices.cacheWrite ?? prices.input) : 0) + usage.cacheReadInputTokens * prices.cacheRead) / 1e6;
17108
- }
17109
- function isQueryRecord(value) {
17110
- if (!value || typeof value !== "object")
17111
- return false;
17112
- const record = value;
17113
- 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";
17114
- }
17115
- function recordUsage(userId, session, usage, context) {
17116
- const cacheReadInputTokens = usage.cacheReadInputTokens ?? 0;
17117
- const inputTokens = context.agent === "claude" ? usage.inputTokens : Math.max(0, usage.inputTokens - cacheReadInputTokens);
17118
- const normalized = {
17119
- inputTokens,
17120
- outputTokens: usage.outputTokens,
17121
- cacheCreationInputTokens: usage.cacheCreationInputTokens ?? 0,
17122
- cacheReadInputTokens
17123
- };
17124
- const record = {
17125
- schemaVersion: 2,
17126
- timestamp: new Date().toISOString(),
17127
- session,
17128
- topicId: context.topicId,
17129
- ...context.providerSessionId ? { providerSessionId: context.providerSessionId } : {},
17130
- agent: context.agent,
17131
- model: context.model,
17132
- ...normalized,
17133
- ...usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {},
17134
- ...usage.contextWindow !== undefined ? { contextWindow: usage.contextWindow } : {},
17135
- estimatedCostUsd: usage.costUsd ?? estimateUsageCost(context.agent, context.model, normalized)
17136
- };
17137
- try {
17138
- appendJsonlEntry(queriesPath(userId), record);
17139
- } catch (e) {
17140
- logger.warn({ err: e, userId }, "token-stats: Failed to record");
17141
- }
17142
- }
17143
- function getStats(userId, from, to) {
17144
- const records = loadRecords(userId);
17145
- const fromTs = from ? new Date(from).getTime() : 0;
17146
- const toTs = to ? new Date(to).getTime() : Infinity;
17147
- if (from && Number.isNaN(fromTs) || to && Number.isNaN(toTs)) {
17148
- logger.warn({ from, to }, "token-stats: Invalid date range, returning empty");
17149
- return {
17150
- total: emptyBucket(),
17151
- byHour: {},
17152
- bySession: {},
17153
- currentSessions: [],
17154
- ignoredLegacyRecords: 0,
17155
- estimatedCostUsd: 0
17156
- };
17157
- }
17158
- const total = emptyBucket();
17159
- const byHour = {};
17160
- const bySession = {};
17161
- const currentSessions = new Map;
17162
- let ignoredLegacyRecords = 0;
17163
- for (const raw of records) {
17164
- if (!isQueryRecord(raw)) {
17165
- ignoredLegacyRecords += 1;
17166
- continue;
17167
- }
17168
- const r = raw;
17169
- const ts = new Date(r.timestamp).getTime();
17170
- if (ts < fromTs || ts > toTs)
17171
- continue;
17172
- const hourKey = r.timestamp.slice(0, 13);
17173
- if (!byHour[hourKey])
17174
- byHour[hourKey] = emptyBucket();
17175
- if (!bySession[r.session])
17176
- bySession[r.session] = emptyBucket();
17177
- for (const bucket of [total, byHour[hourKey], bySession[r.session]]) {
17178
- bucket.inputTokens += r.inputTokens;
17179
- bucket.outputTokens += r.outputTokens;
17180
- bucket.cacheCreationInputTokens += r.cacheCreationInputTokens;
17181
- bucket.cacheReadInputTokens += r.cacheReadInputTokens;
17182
- bucket.queries += 1;
17183
- bucket.estimatedCostUsd += r.estimatedCostUsd;
17184
- }
17185
- if (r.contextTokens !== undefined && r.contextWindow !== undefined && r.contextWindow > 0) {
17186
- currentSessions.set(r.topicId, {
17187
- timestamp: r.timestamp,
17188
- topicId: r.topicId,
17189
- topicTitle: r.session,
17190
- ...r.providerSessionId ? { providerSessionId: r.providerSessionId } : {},
17191
- agent: r.agent,
17192
- model: r.model,
17193
- contextTokens: r.contextTokens,
17194
- contextWindow: r.contextWindow
17195
- });
17196
- }
17197
- }
17198
- return {
17199
- total,
17200
- byHour,
17201
- bySession,
17202
- currentSessions: [...currentSessions.values()].sort((a, b) => b.timestamp.localeCompare(a.timestamp)),
17203
- ignoredLegacyRecords,
17204
- estimatedCostUsd: calcCost(total)
17205
- };
17206
- }
17207
- function getTopicStats(userId, topicId) {
17208
- const total = emptyBucket();
17209
- let currentSession;
17210
- for (const raw of loadRecords(userId)) {
17211
- if (!isQueryRecord(raw) || raw.topicId !== topicId)
17212
- continue;
17213
- total.inputTokens += raw.inputTokens;
17214
- total.outputTokens += raw.outputTokens;
17215
- total.cacheCreationInputTokens += raw.cacheCreationInputTokens;
17216
- total.cacheReadInputTokens += raw.cacheReadInputTokens;
17217
- total.queries += 1;
17218
- total.estimatedCostUsd += raw.estimatedCostUsd;
17219
- if (raw.contextTokens !== undefined && raw.contextWindow !== undefined && raw.contextWindow > 0 && (!currentSession || raw.timestamp > currentSession.timestamp)) {
17220
- currentSession = {
17221
- timestamp: raw.timestamp,
17222
- topicId: raw.topicId,
17223
- topicTitle: raw.session,
17224
- ...raw.providerSessionId ? { providerSessionId: raw.providerSessionId } : {},
17225
- agent: raw.agent,
17226
- model: raw.model,
17227
- contextTokens: raw.contextTokens,
17228
- contextWindow: raw.contextWindow
17229
- };
17230
- }
17231
- }
17232
- return { topicId, ...total, ...currentSession ? { currentSession } : {} };
17233
- }
17234
- var TOKEN_PRICES;
17235
- var init_token_stats = __esm(async () => {
17236
- init_jsonl();
17237
- init_logger();
17238
- await init_storage_host();
17239
- TOKEN_PRICES = {
17240
- "codex:gpt-5.6-sol": { input: 5, cacheRead: 0.5, output: 30 },
17241
- "codex:gpt-5.6-terra": { input: 2.5, cacheRead: 0.25, output: 15 },
17242
- "codex:gpt-5.6-luna": { input: 1, cacheRead: 0.1, output: 6 },
17243
- "claude:fable": { input: 10, cacheWrite: 12.5, cacheRead: 1, output: 50 },
17244
- "claude:opus": { input: 5, cacheWrite: 6.25, cacheRead: 0.5, output: 25 },
17245
- "claude:sonnet": { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 },
17246
- "maestro:kimi-k3": { input: 3, cacheRead: 0.3, output: 15 },
17247
- "maestro:kimi-k2.7-code": { input: 0.95, cacheRead: 0.19, output: 4 },
17248
- "maestro:deepseek-pro": { input: 0.435, cacheRead: 0.003625, output: 0.87 },
17249
- "maestro:deepseek-flash": { input: 0.14, cacheRead: 0.0028, output: 0.28 }
17250
- };
17251
- });
17252
-
17253
17373
  // ../../packages/core/src/runtime/turn-event-stream.ts
17254
17374
  import { randomUUID as randomUUID15 } from "crypto";
17255
17375
  import { realpathSync as realpathSync5, statSync as statSync9 } from "fs";
@@ -17365,10 +17485,11 @@ async function runTurnEventStream(topicId, topicTitle, queryId, events, control,
17365
17485
  agentType,
17366
17486
  model,
17367
17487
  sourceNode: execution?.sourceNode,
17488
+ ...execution?.threadRootId ? { threadRootId: execution.threadRootId } : {},
17368
17489
  usage,
17369
17490
  createdAt: new Date().toISOString()
17370
17491
  };
17371
- appendApiMessage(message);
17492
+ appendApiMessage(message, execution?.threadRootId ? { updateTopicLastMessageAt: false } : undefined);
17372
17493
  hub.broadcastMessage(topicId, message);
17373
17494
  lastVisibleMessageId = message.id;
17374
17495
  visibleMessageIds.push(message.id);
@@ -17890,7 +18011,7 @@ __export(exports_app_settings, {
17890
18011
  getGlobalAiName: () => getGlobalAiName,
17891
18012
  DEFAULT_AI_NAME: () => DEFAULT_AI_NAME
17892
18013
  });
17893
- 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";
17894
18015
  import { dirname as dirname14, join as join27 } from "path";
17895
18016
  function settingsFile() {
17896
18017
  return join27(resolveStorageDataDir(), "otium-settings.json");
@@ -17920,7 +18041,7 @@ function setGlobalAiName(name) {
17920
18041
  aiName = name.trim() || DEFAULT_AI_NAME;
17921
18042
  try {
17922
18043
  mkdirSync18(dirname14(path), { recursive: true });
17923
- writeFileSync15(path, JSON.stringify({ aiName }, null, 2));
18044
+ writeFileSync16(path, JSON.stringify({ aiName }, null, 2));
17924
18045
  } catch {}
17925
18046
  return aiName;
17926
18047
  }
@@ -18417,6 +18538,7 @@ async function drainOneDurableUserTurn() {
18417
18538
  bridgeSessionFromHistory: execution?.bridgeSessionFromHistory,
18418
18539
  peerBridge: execution?.peerBridge,
18419
18540
  from: execution?.from,
18541
+ threadRootId: execution?.threadRootId,
18420
18542
  _queryId: request.requestId,
18421
18543
  _runtimeEpoch: execution?.runtimeEpoch ?? request.topicEpoch,
18422
18544
  onSettled: () => {
@@ -18489,6 +18611,7 @@ function startAiTurn(params) {
18489
18611
  let sessionId = sessionResolution.sessionId;
18490
18612
  const deferredSessionId = params.sessionId === undefined && !sessionResolution.isolated ? undefined : sessionId;
18491
18613
  const sourceNode = params.sourceNode;
18614
+ const threadRootId = params.threadRootId;
18492
18615
  const topicId = topic.id;
18493
18616
  const requestId = params.requestId;
18494
18617
  const depth = params.depth;
@@ -19017,7 +19140,7 @@ function startAiTurn(params) {
19017
19140
  WsHub.get().broadcastTyping(topicId, "ai");
19018
19141
  WsHub.get().broadcastAiActive(topicId, queryId);
19019
19142
  }
19020
- 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) => {
19021
19144
  let outcome = streamOutcome;
19022
19145
  if (outcome.kind === "session-expired") {
19023
19146
  const retry = resolveSessionRetry({
@@ -20277,7 +20400,8 @@ function gatewayPayloadHash(params, requestId, actorUserId) {
20277
20400
  params.text,
20278
20401
  params.clientMessageId,
20279
20402
  requestId,
20280
- params.allowAutoContinue ?? true
20403
+ params.allowAutoContinue ?? true,
20404
+ params.threadRootId ?? null
20281
20405
  ])).digest("hex");
20282
20406
  }
20283
20407
  function submitRuntimeGatewayTurn(params) {
@@ -20297,6 +20421,7 @@ function submitRuntimeGatewayTurn(params) {
20297
20421
  sourceAdapter: "runtime-gateway",
20298
20422
  sourceMessageId: params.clientMessageId,
20299
20423
  text: params.text,
20424
+ ...params.threadRootId ? { threadRootId: params.threadRootId } : {},
20300
20425
  createdAt
20301
20426
  };
20302
20427
  const submission = {
@@ -20331,7 +20456,8 @@ function submitRuntimeGatewayTurn(params) {
20331
20456
  sessionIdSpecified: true,
20332
20457
  conversationPrompts: [params.text],
20333
20458
  loggedUserMessageCount: 0,
20334
- vaultUserId: params.vaultUserId
20459
+ vaultUserId: params.vaultUserId,
20460
+ ...params.threadRootId ? { threadRootId: params.threadRootId } : {}
20335
20461
  }
20336
20462
  });
20337
20463
  const acceptedEvent = appendRuntimeEvent("runtime-gateway-ingress", {
@@ -20524,7 +20650,11 @@ function registerTopic(opts) {
20524
20650
  throw new TopicValidationError("Manager rooms are system-managed");
20525
20651
  }
20526
20652
  const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
20527
- 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 });
20528
20658
  if (conflict) {
20529
20659
  throw new TopicValidationError(`A topic named "${title}" already exists on ${surface}`);
20530
20660
  }
@@ -20561,6 +20691,7 @@ function registerTopic(opts) {
20561
20691
  aiMode,
20562
20692
  participants: [{ userId: opts.userId, role: "owner" }],
20563
20693
  surface,
20694
+ surfaceScope,
20564
20695
  createdAt: now,
20565
20696
  lastMessageAt: now
20566
20697
  };
@@ -21714,7 +21845,7 @@ var init_file_ops = __esm(() => {
21714
21845
 
21715
21846
  // ../../packages/core/src/runtime/inbox.ts
21716
21847
  import { createHash as createHash9, randomUUID as randomUUID20 } from "crypto";
21717
- 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";
21718
21849
  import { join as join32 } from "path";
21719
21850
  async function createAskForkPlan(options) {
21720
21851
  const snapshot = structuredClone(options.entries);
@@ -22004,7 +22135,7 @@ function sweepScheduledSessionInbox(nowMs = Date.now()) {
22004
22135
  total: due.length + pending.length
22005
22136
  }, "self-schedule: promotion interrupted; retaining only the unpromoted entries");
22006
22137
  try {
22007
- writeFileSync16(drained.processingPath, `${unfinished.map((entry) => JSON.stringify(entry)).join(`
22138
+ writeFileSync17(drained.processingPath, `${unfinished.map((entry) => JSON.stringify(entry)).join(`
22008
22139
  `)}
22009
22140
  `);
22010
22141
  } catch (rewriteErr) {
@@ -22411,13 +22542,16 @@ __export(exports_src, {
22411
22542
  startBashrsCompletionsWorker: () => startBashrsCompletionsWorker,
22412
22543
  startAskUserQuestionGateOwner: () => startAskUserQuestionGateOwner,
22413
22544
  startAiTurn: () => startAiTurn,
22545
+ stampUnscopedOtiumTopics: () => stampUnscopedOtiumTopics,
22414
22546
  setTopicSurfaces: () => setTopicSurfaces,
22415
22547
  setTopicSessionId: () => setTopicSessionId,
22548
+ setSurfaceScopeRequired: () => setSurfaceScopeRequired,
22416
22549
  setRuntimeMcpPort: () => setRuntimeMcpPort,
22417
22550
  setRuntimeBus: () => setRuntimeBus,
22418
22551
  setPlaywrightUnavailableNotifier: () => setPlaywrightUnavailableNotifier,
22419
22552
  setNodeMcpServers: () => setNodeMcpServers,
22420
22553
  setFileHooks: () => setFileHooks,
22554
+ setDefaultSurfaceScope: () => setDefaultSurfaceScope,
22421
22555
  setBashrsCompletionSink: () => setBashrsCompletionSink,
22422
22556
  setApiTopicConfig: () => setApiTopicConfig,
22423
22557
  sessionInboxPath: () => sessionInboxPath,
@@ -22459,6 +22593,7 @@ __export(exports_src, {
22459
22593
  onShutdown: () => onShutdown,
22460
22594
  normalizeVaultKey: () => normalizeVaultKey,
22461
22595
  normalizeTopicSurface: () => normalizeTopicSurface,
22596
+ normalizeSurfaceScope: () => normalizeSurfaceScope,
22462
22597
  nodeRequestHandlerNames: () => nodeRequestHandlerNames,
22463
22598
  modelOwner: () => modelOwner,
22464
22599
  markPlaywrightUnavailable: () => markPlaywrightUnavailable,
@@ -22486,6 +22621,7 @@ __export(exports_src, {
22486
22621
  isTranscriptionConfigured: () => isTranscriptionConfigured,
22487
22622
  isTopicVisible: () => isTopicVisible,
22488
22623
  isTopicRunning: () => isTopicRunning,
22624
+ isSurfaceScopeRequired: () => isSurfaceScopeRequired,
22489
22625
  isSensitivePath: () => isSensitivePath,
22490
22626
  isParticipant: () => isParticipant,
22491
22627
  isHostedMcpSurface: () => isHostedMcpSurface,
@@ -22535,6 +22671,7 @@ __export(exports_src, {
22535
22671
  deleteVaultEntry: () => deleteVaultEntry,
22536
22672
  deleteTopicCascade: () => deleteTopicCascade,
22537
22673
  defaultTopicSurface: () => defaultTopicSurface,
22674
+ defaultSurfaceScope: () => defaultSurfaceScope,
22538
22675
  db: () => db,
22539
22676
  createSubagentManagementToolDefinitions: () => createSubagentManagementToolDefinitions,
22540
22677
  createSpawnSubagentToolDefinition: () => createSpawnSubagentToolDefinition,
@@ -22832,6 +22969,7 @@ __export(exports_node_host, {
22832
22969
  getVisibleTopics: () => getVisibleTopics,
22833
22970
  getTopicStats: () => getTopicStats,
22834
22971
  getTopic: () => getTopic,
22972
+ getApiMessage: () => getApiMessage,
22835
22973
  flushBashrsCompletions: () => flushBashrsCompletions,
22836
22974
  executeVaultCommand: () => executeVaultCommand,
22837
22975
  ensurePersonalGeneral: () => ensurePersonalGeneral,
@@ -23166,6 +23304,7 @@ __export(exports_mcp_runtime_host, {
23166
23304
  dispatchPeerRuntimeFile: () => dispatchPeerRuntimeFile,
23167
23305
  dispatchPeerRuntimeAskUser: () => dispatchPeerRuntimeAskUser,
23168
23306
  deleteTopicCascade: () => deleteTopicCascade,
23307
+ defaultTopicSurface: () => defaultTopicSurface,
23169
23308
  createSubagentManagementToolDefinitions: () => createSubagentManagementToolDefinitions,
23170
23309
  createSpawnSubagentToolDefinition: () => createSpawnSubagentToolDefinition,
23171
23310
  createSelfConfigToolDefinitions: () => createSelfConfigToolDefinitions,
@@ -23222,6 +23361,9 @@ var init_mcp_runtime_host = __esm(async () => {
23222
23361
 
23223
23362
  // ../../packages/mcp/src/node-tools.ts
23224
23363
  import { z as z6 } from "zod";
23364
+ function callerSurface(ctx) {
23365
+ return getTopic(ctx.topicId)?.surface ?? defaultTopicSurface();
23366
+ }
23225
23367
  function resolveTopicForUser(ctx, ref) {
23226
23368
  const trimmed = ref.trim();
23227
23369
  if (!trimmed)
@@ -23233,7 +23375,7 @@ function resolveTopicForUser(ctx, ref) {
23233
23375
  return { error: notFound };
23234
23376
  return { topic: byId };
23235
23377
  }
23236
- const byTitle = getTopicByNameForUser(trimmed, ctx.userId);
23378
+ const byTitle = getTopicByNameForUser(trimmed, ctx.userId, { surface: callerSurface(ctx) });
23237
23379
  if (byTitle)
23238
23380
  return { topic: byTitle };
23239
23381
  return { error: notFound };
@@ -23255,6 +23397,7 @@ function registerNodeTools(server, ctx) {
23255
23397
  const topic = registerTopic({
23256
23398
  title,
23257
23399
  userId: ctx.userId,
23400
+ surface: callerSurface(ctx),
23258
23401
  agent,
23259
23402
  model,
23260
23403
  effort,
@@ -23276,7 +23419,7 @@ function registerNodeTools(server, ctx) {
23276
23419
  }
23277
23420
  });
23278
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 () => {
23279
- const topics = getTopics().filter((topic) => isParticipant(topic, ctx.userId));
23422
+ const topics = getTopics({ surface: callerSurface(ctx) }).filter((topic) => isParticipant(topic, ctx.userId));
23280
23423
  if (topics.length === 0) {
23281
23424
  return textResult("No topics found. Use register_topic to create one.");
23282
23425
  }
@@ -24253,13 +24396,15 @@ function currentTopic(context) {
24253
24396
  return topic;
24254
24397
  }
24255
24398
  function targetCatalog(context) {
24256
- 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;
24257
24402
  return createSessionTargetCatalog({
24258
24403
  currentTopicId: context.currentTopicId,
24259
24404
  currentTopicName: context.currentTopic,
24260
24405
  currentSurface: surface,
24261
24406
  isAgent: isAgentKind,
24262
- 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) => ({
24263
24408
  id: topic.id,
24264
24409
  title: topic.title,
24265
24410
  kind: topic.kind ?? null,
@@ -24311,7 +24456,7 @@ function createDefaultSessionCommMcpHost() {
24311
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 ? `
24312
24457
  description: ${topic.description.slice(0, 80)}` : ""}`);
24313
24458
  if (!identity.restricted) {
24314
- const peers = await peerSessionsForUser(context.userId, context.peerHostQueryId);
24459
+ const peers = await peerSessionsForUser(context.userId, context.peerHostQueryId, context.currentTopicId);
24315
24460
  for (const node of peers.nodes ?? []) {
24316
24461
  for (const session of node.sessions ?? []) {
24317
24462
  if (session.agent)
@@ -25156,7 +25301,7 @@ import {
25156
25301
  renameSync as renameSync11,
25157
25302
  statSync as statSync15,
25158
25303
  unlinkSync as unlinkSync19,
25159
- writeFileSync as writeFileSync17
25304
+ writeFileSync as writeFileSync18
25160
25305
  } from "fs";
25161
25306
  import { basename as basename7, dirname as dirname16, join as join36, relative as relative2, resolve as resolve17 } from "path";
25162
25307
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -25194,7 +25339,7 @@ function acquireRecoveryGuard(lockPath) {
25194
25339
  try {
25195
25340
  const fd = openSync4(guardPath, "wx");
25196
25341
  try {
25197
- writeFileSync17(fd, ownerToken, "utf-8");
25342
+ writeFileSync18(fd, ownerToken, "utf-8");
25198
25343
  } catch (error2) {
25199
25344
  closeSync4(fd);
25200
25345
  try {
@@ -25227,7 +25372,7 @@ function acquireFileLock(path) {
25227
25372
  try {
25228
25373
  const fd = openSync4(lockPath, "wx");
25229
25374
  try {
25230
- writeFileSync17(fd, ownerToken, "utf-8");
25375
+ writeFileSync18(fd, ownerToken, "utf-8");
25231
25376
  } catch (error2) {
25232
25377
  closeSync4(fd);
25233
25378
  try {
@@ -25271,7 +25416,7 @@ function acquireFileLock(path) {
25271
25416
  }
25272
25417
  function atomicWriteFile(path, content) {
25273
25418
  const temporaryPath = `${path}.tmp.${process.pid}.${Date.now()}`;
25274
- writeFileSync17(temporaryPath, content, "utf-8");
25419
+ writeFileSync18(temporaryPath, content, "utf-8");
25275
25420
  try {
25276
25421
  renameSync11(temporaryPath, path);
25277
25422
  } catch (error2) {
@@ -26151,7 +26296,7 @@ ${fresh.join(`
26151
26296
  const existing = readFileSync23(skillPath, "utf-8");
26152
26297
  finalContent = mergeGotchas(content, extractGotchas(existing));
26153
26298
  } catch {}
26154
- writeFileSync17(skillPath, finalContent, "utf-8");
26299
+ writeFileSync18(skillPath, finalContent, "utf-8");
26155
26300
  return {
26156
26301
  content: [
26157
26302
  {
@@ -26170,7 +26315,7 @@ function writeSummaryDocument(rawTopic, content, dateStr) {
26170
26315
  let counter = 1;
26171
26316
  while (true) {
26172
26317
  try {
26173
- writeFileSync17(resolve17(runtime().summariesDir, summaryName), content, {
26318
+ writeFileSync18(resolve17(runtime().summariesDir, summaryName), content, {
26174
26319
  encoding: "utf-8",
26175
26320
  flag: "wx"
26176
26321
  });
@@ -26210,7 +26355,7 @@ function writeTopicDocument(rawTopic, content) {
26210
26355
  const briefPath = resolve17(runtime().topicsDir, `${fileSlug}.md`);
26211
26356
  ensureDir(dirname16(briefPath));
26212
26357
  const existed = existsSync27(briefPath);
26213
- writeFileSync17(briefPath, content, "utf-8");
26358
+ writeFileSync18(briefPath, content, "utf-8");
26214
26359
  const setTopicBrief2 = runtime().host.setTopicBrief;
26215
26360
  let sqliteUpdated = false;
26216
26361
  if (topicId && setTopicBrief2) {
@@ -26877,7 +27022,7 @@ import {
26877
27022
  renameSync as renameSync12,
26878
27023
  statSync as statSync16,
26879
27024
  unlinkSync as unlinkSync20,
26880
- writeFileSync as writeFileSync18
27025
+ writeFileSync as writeFileSync19
26881
27026
  } from "fs";
26882
27027
  import { basename as basename8, join as join37 } from "path";
26883
27028
  function startLogRotation() {
@@ -26977,7 +27122,7 @@ function removeSentFilesForTopic(userId, topicName) {
26977
27122
  }
26978
27123
  });
26979
27124
  if (remaining.length > 0) {
26980
- writeFileSync18(file, `${remaining.join(`
27125
+ writeFileSync19(file, `${remaining.join(`
26981
27126
  `)}
26982
27127
  `);
26983
27128
  } else {
@@ -27499,6 +27644,7 @@ __export(exports_storage_public, {
27499
27644
  tasks: () => exports_tasks,
27500
27645
  taskScopeKey: () => taskScopeKey,
27501
27646
  taskFileMtimeNs: () => taskFileMtimeNs,
27647
+ stampUnscopedOtiumTopics: () => stampUnscopedOtiumTopics,
27502
27648
  softDeleteApiMessagesByIdPrefix: () => softDeleteApiMessagesByIdPrefix,
27503
27649
  softDeleteApiMessage: () => softDeleteApiMessage,
27504
27650
  settleTopicArchiveJob: () => settleTopicArchiveJob,
@@ -27513,10 +27659,12 @@ __export(exports_storage_public, {
27513
27659
  setTopicAgentAndSession: () => setTopicAgentAndSession,
27514
27660
  setTopicAgentAndClearSession: () => setTopicAgentAndClearSession,
27515
27661
  setTopicAgent: () => setTopicAgent,
27662
+ setSurfaceScopeRequired: () => setSurfaceScopeRequired,
27516
27663
  setSessionForTopic: () => setSessionForTopic,
27517
27664
  setLastShownConfig: () => setLastShownConfig,
27518
27665
  setGlobalAiName: () => setGlobalAiName,
27519
27666
  setDmSessionId: () => setDmSessionId,
27667
+ setDefaultSurfaceScope: () => setDefaultSurfaceScope,
27520
27668
  setApiTopicConfig: () => setApiTopicConfig,
27521
27669
  setApiTopicAgent: () => setApiTopicAgent,
27522
27670
  setApiMessageReactions: () => setApiMessageReactions,
@@ -27549,6 +27697,7 @@ __export(exports_storage_public, {
27549
27697
  normalizeTopicSurface: () => normalizeTopicSurface,
27550
27698
  normalizeTopicState: () => normalizeTopicState,
27551
27699
  normalizeTopicKind: () => normalizeTopicKind,
27700
+ normalizeSurfaceScope: () => normalizeSurfaceScope,
27552
27701
  normalizeAiMode: () => normalizeAiMode,
27553
27702
  markPendingAskState: () => markPendingAskState,
27554
27703
  markPendingAskSources: () => markPendingAskSources,
@@ -27564,6 +27713,7 @@ __export(exports_storage_public, {
27564
27713
  isTopicVisible: () => isTopicVisible,
27565
27714
  isTopicSummaryFile: () => isTopicSummaryFile,
27566
27715
  isTopicBriefFile: () => isTopicBriefFile,
27716
+ isSurfaceScopeRequired: () => isSurfaceScopeRequired,
27567
27717
  isRuntimeProcessLeaseAlive: () => isRuntimeProcessLeaseAlive,
27568
27718
  inferTopicKind: () => inferTopicKind,
27569
27719
  inferAiMode: () => inferAiMode,
@@ -27616,6 +27766,7 @@ __export(exports_storage_public, {
27616
27766
  findRecentUserMessage: () => findRecentUserMessage,
27617
27767
  findLastSessionIdForAgent: () => findLastSessionIdForAgent,
27618
27768
  describePendingAskState: () => describePendingAskState,
27769
+ deleteTopicStats: () => deleteTopicStats,
27619
27770
  deleteTopicBrief: () => deleteTopicBrief,
27620
27771
  deleteTopicArchiveState: () => deleteTopicArchiveState,
27621
27772
  deleteTopic: () => deleteTopic,
@@ -27624,6 +27775,7 @@ __export(exports_storage_public, {
27624
27775
  deleteMessagesForTopic: () => deleteMessagesForTopic,
27625
27776
  deleteApiTopicConfig: () => deleteApiTopicConfig,
27626
27777
  defaultTopicSurface: () => defaultTopicSurface,
27778
+ defaultSurfaceScope: () => defaultSurfaceScope,
27627
27779
  db: () => db2,
27628
27780
  createTasks: () => createTasks,
27629
27781
  createPendingAsk: () => createPendingAsk,
@@ -28483,7 +28635,7 @@ var init_spec = __esm(() => {
28483
28635
  });
28484
28636
 
28485
28637
  // ../../packages/mcp-host/src/manifest.ts
28486
- 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";
28487
28639
  import { dirname as dirname17 } from "path";
28488
28640
  import { z as z14 } from "zod";
28489
28641
 
@@ -28550,7 +28702,7 @@ class McpManifest {
28550
28702
  servers: [...this.entries.values()]
28551
28703
  };
28552
28704
  const tmp = `${this.file}.tmp-${process.pid}`;
28553
- writeFileSync19(tmp, `${JSON.stringify(payload, null, 2)}
28705
+ writeFileSync20(tmp, `${JSON.stringify(payload, null, 2)}
28554
28706
  `);
28555
28707
  renameSync13(tmp, this.file);
28556
28708
  }
@@ -28585,7 +28737,7 @@ import {
28585
28737
  readFileSync as readFileSync25,
28586
28738
  renameSync as renameSync14,
28587
28739
  unlinkSync as unlinkSync21,
28588
- writeFileSync as writeFileSync20
28740
+ writeFileSync as writeFileSync21
28589
28741
  } from "fs";
28590
28742
  import { connect, createServer as createServer3 } from "net";
28591
28743
  import { join as join38 } from "path";
@@ -28902,7 +29054,7 @@ class McpHost {
28902
29054
  mkdirSync27(this.portsDir, { recursive: true });
28903
29055
  const file = join38(this.portsDir, fileName);
28904
29056
  const tmp = `${file}.tmp-${process.pid}`;
28905
- writeFileSync20(tmp, String(port));
29057
+ writeFileSync21(tmp, String(port));
28906
29058
  renameSync14(tmp, file);
28907
29059
  } catch (e) {
28908
29060
  this.log("warn", "Failed to write MCP port file", { fileName, port, err: String(e) });
@@ -29020,7 +29172,7 @@ import {
29020
29172
  readFileSync as readFileSync26,
29021
29173
  rmSync as rmSync9,
29022
29174
  statSync as statSync18,
29023
- writeFileSync as writeFileSync21
29175
+ writeFileSync as writeFileSync22
29024
29176
  } from "fs";
29025
29177
  import { basename as basename10, extname as extname4, join as join39 } from "path";
29026
29178
  function safeExtension(filename) {
@@ -29110,7 +29262,7 @@ class NodeFileStore {
29110
29262
  ...access.visibility ? { visibility: access.visibility } : {}
29111
29263
  };
29112
29264
  copyFileSync4(absPath, savedPath);
29113
- writeFileSync21(this.#metadataPath(fileId), JSON.stringify(metadata), { mode: 384 });
29265
+ writeFileSync22(this.#metadataPath(fileId), JSON.stringify(metadata), { mode: 384 });
29114
29266
  return this.#attachment(fileId, metadata);
29115
29267
  } catch (error2) {
29116
29268
  rmSync9(savedPath, { force: true });
@@ -29303,12 +29455,38 @@ import {
29303
29455
  readFileSync as readFileSync27,
29304
29456
  renameSync as renameSync15,
29305
29457
  unlinkSync as unlinkSync22,
29306
- writeFileSync as writeFileSync22
29458
+ writeFileSync as writeFileSync23
29307
29459
  } from "fs";
29308
29460
  import { dirname as dirname18, resolve as resolve21 } from "path";
29309
29461
  function jsonError(status, error2) {
29310
29462
  return Response.json({ ok: false, error: error2 }, { status });
29311
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
+ }
29312
29490
  function topicServiceError(error2) {
29313
29491
  const status = error2.code === "TOPIC_NOT_FOUND" ? 404 : error2.code === "TOPIC_FORBIDDEN" ? 403 : 400;
29314
29492
  return jsonError(status, error2.message);
@@ -29363,7 +29541,21 @@ function runtimeEvent(event) {
29363
29541
  createdAt: event.createdAt
29364
29542
  };
29365
29543
  }
29366
- 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
+ };
29367
29559
  let cursor = Math.max(0, after);
29368
29560
  return createPollingSseStream(req, {
29369
29561
  ready: { v: NODE_RUNTIME_CONTRACT_VERSION, cursor },
@@ -29374,7 +29566,7 @@ function createRuntimeContractEventStream(req, after, topicId) {
29374
29566
  break;
29375
29567
  for (const event of events) {
29376
29568
  cursor = event.seq;
29377
- if (!topicId || event.topicId === topicId) {
29569
+ if ((!topicId || event.topicId === topicId) && eventInScope(event.topicId)) {
29378
29570
  send("runtime", runtimeEvent(event), event.seq);
29379
29571
  }
29380
29572
  }
@@ -29385,8 +29577,8 @@ function createRuntimeContractEventStream(req, after, topicId) {
29385
29577
  }
29386
29578
  });
29387
29579
  }
29388
- function createEventStream(req, userId, after) {
29389
- 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));
29390
29582
  let cursor = Math.max(0, after);
29391
29583
  return createPollingSseStream(req, {
29392
29584
  ready: { protocolVersion: NODE_CONTROL_PROTOCOL_VERSION, cursor },
@@ -29399,7 +29591,8 @@ function createEventStream(req, userId, after) {
29399
29591
  cursor = event.seq;
29400
29592
  if (event.type === "topic-created" || event.type === "topic-updated") {
29401
29593
  const topic = getTopic(event.topicId);
29402
- if (topic && isParticipant(topic, userId))
29594
+ const admissible = topic && isParticipant(topic, userId) && (!surface || topic.surface === surface);
29595
+ if (admissible)
29403
29596
  allowedTopics.add(event.topicId);
29404
29597
  else
29405
29598
  allowedTopics.delete(event.topicId);
@@ -29451,6 +29644,10 @@ function createNodeControlHandler(options) {
29451
29644
  if (body.v !== NODE_RUNTIME_CONTRACT_VERSION)
29452
29645
  return jsonError(400, "Unsupported v");
29453
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
+ }
29454
29651
  const userId = requiredText(body.userId, "userId");
29455
29652
  const actorUserId = body.actorUserId === undefined ? undefined : requiredText(body.actorUserId, "actorUserId");
29456
29653
  const actorLabel = body.actorLabel === undefined ? undefined : requiredText(body.actorLabel, "actorLabel");
@@ -29458,12 +29655,19 @@ function createNodeControlHandler(options) {
29458
29655
  const text2 = requiredText(body.text, "text");
29459
29656
  const clientMessageId = requiredText(body.clientMessageId, "clientMessageId");
29460
29657
  const requestId = body.requestId === undefined ? undefined : requiredText(body.requestId, "requestId");
29658
+ const threadRootId = body.threadRootId === undefined ? undefined : requiredText(body.threadRootId, "threadRootId");
29461
29659
  const topic = getTopic(topicId);
29462
29660
  if (!topic)
29463
29661
  return jsonError(404, "Topic not found");
29464
29662
  if (!topic.participants.some((participant) => participant.userId === userId)) {
29465
29663
  return jsonError(404, "Topic not found");
29466
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
+ }
29467
29671
  const submission = submitRuntimeGatewayTurn({
29468
29672
  topic,
29469
29673
  userId,
@@ -29473,7 +29677,8 @@ function createNodeControlHandler(options) {
29473
29677
  text: text2,
29474
29678
  clientMessageId,
29475
29679
  requestId,
29476
- allowAutoContinue: body.allowAutoContinue !== false
29680
+ allowAutoContinue: body.allowAutoContinue !== false,
29681
+ ...threadRootId ? { threadRootId } : {}
29477
29682
  });
29478
29683
  return Response.json({
29479
29684
  ok: true,
@@ -29490,13 +29695,15 @@ function createNodeControlHandler(options) {
29490
29695
  if (req.method === "GET" && runtimePath === "/events") {
29491
29696
  const parsed = Number.parseInt(url.searchParams.get("after") ?? "0", 10);
29492
29697
  const topicId = url.searchParams.get("topicId")?.trim() || undefined;
29493
- return createRuntimeContractEventStream(req, Number.isFinite(parsed) ? parsed : 0, topicId);
29698
+ return createRuntimeContractEventStream(req, Number.isFinite(parsed) ? parsed : 0, topicId, requestSurfaceScope(req));
29494
29699
  }
29495
29700
  const runtimeMessagesMatch = runtimePath.match(/^\/topics\/([^/]+)\/messages$/);
29496
29701
  if (runtimeMessagesMatch && req.method === "GET") {
29497
29702
  const topicId = decodeURIComponent(runtimeMessagesMatch[1]);
29498
- if (!getTopic(topicId))
29703
+ const messagesTopic = getTopic(topicId);
29704
+ if (!messagesTopic || !topicInRequestScope(req, messagesTopic)) {
29499
29705
  return jsonError(404, "Topic not found");
29706
+ }
29500
29707
  const cursor = url.searchParams.get("cursor");
29501
29708
  const parsedLimit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10);
29502
29709
  const result = listApiMessages(topicId, {
@@ -29506,7 +29713,7 @@ function createNodeControlHandler(options) {
29506
29713
  return Response.json({ ok: true, v: NODE_RUNTIME_CONTRACT_VERSION, ...result });
29507
29714
  }
29508
29715
  if (req.method === "GET" && runtimePath === "/topics") {
29509
- const topics = getVisibleTopics({ surface: "otium" });
29716
+ const topics = gatewayVisibleTopics(req);
29510
29717
  return Response.json({
29511
29718
  ok: true,
29512
29719
  v: NODE_RUNTIME_CONTRACT_VERSION,
@@ -29529,6 +29736,7 @@ function createNodeControlHandler(options) {
29529
29736
  userId,
29530
29737
  kind: "agent",
29531
29738
  surface: "otium",
29739
+ ...requestSurfaceScope(req),
29532
29740
  ...agent ? { agent } : {}
29533
29741
  });
29534
29742
  return Response.json({ ok: true, v: NODE_RUNTIME_CONTRACT_VERSION, topic }, { status: 201 });
@@ -29540,7 +29748,7 @@ function createNodeControlHandler(options) {
29540
29748
  return jsonError(400, "Unsupported v");
29541
29749
  const topicId = decodeURIComponent(importMatch[1]);
29542
29750
  const topic = getTopic(topicId);
29543
- if (!topic)
29751
+ if (!topic || !topicInRequestScope(req, topic))
29544
29752
  return jsonError(404, "Topic not found");
29545
29753
  if (!Array.isArray(body.messages)) {
29546
29754
  return jsonError(400, "messages must be an array");
@@ -29585,6 +29793,8 @@ function createNodeControlHandler(options) {
29585
29793
  const runtimeTopicMatch = runtimePath.match(/^\/topics\/([^/]+)$/);
29586
29794
  if (runtimeTopicMatch && req.method === "GET") {
29587
29795
  const topic = getTopic(decodeURIComponent(runtimeTopicMatch[1]));
29796
+ if (topic && !topicInRequestScope(req, topic))
29797
+ return jsonError(404, "Topic not found");
29588
29798
  if (!topic)
29589
29799
  return jsonError(404, "Topic not found");
29590
29800
  return Response.json({ ok: true, v: NODE_RUNTIME_CONTRACT_VERSION, topic });
@@ -29608,7 +29818,7 @@ function createNodeControlHandler(options) {
29608
29818
  }
29609
29819
  if (req.method === "GET" && path === "/session") {
29610
29820
  const userId = requiredText(url.searchParams.get("user"), "user");
29611
- ensurePersonalGeneral(userId);
29821
+ ensurePersonalGeneral(userId, requestedSurface(url));
29612
29822
  return Response.json({
29613
29823
  ok: true,
29614
29824
  protocolVersion: NODE_CONTROL_PROTOCOL_VERSION,
@@ -29679,7 +29889,7 @@ function createNodeControlHandler(options) {
29679
29889
  if (req.method === "GET" && path === "/events") {
29680
29890
  const userId = requiredText(url.searchParams.get("user"), "user");
29681
29891
  const parsed = Number.parseInt(url.searchParams.get("after") ?? "0", 10);
29682
- return createEventStream(req, userId, Number.isFinite(parsed) ? parsed : 0);
29892
+ return createEventStream(req, userId, Number.isFinite(parsed) ? parsed : 0, requestedSurface(url));
29683
29893
  }
29684
29894
  const messagesMatch = path.match(/^\/topics\/([^/]+)\/messages$/);
29685
29895
  if (messagesMatch && req.method === "GET") {
@@ -29876,7 +30086,7 @@ function writeNodeDaemonInfo(port, startedAt) {
29876
30086
  };
29877
30087
  mkdirSync29(dirname18(NODE_DAEMON_INFO_PATH), { recursive: true });
29878
30088
  const temporary = `${NODE_DAEMON_INFO_PATH}.${process.pid}.${randomUUID25()}.tmp`;
29879
- writeFileSync22(temporary, `${JSON.stringify(info, null, 2)}
30089
+ writeFileSync23(temporary, `${JSON.stringify(info, null, 2)}
29880
30090
  `, { mode: 384 });
29881
30091
  chmodSync6(temporary, 384);
29882
30092
  renameSync15(temporary, NODE_DAEMON_INFO_PATH);
@@ -29964,7 +30174,7 @@ async function stopNodeDaemon(timeoutMs = 3000) {
29964
30174
  throw new Error(`node shutdown returned HTTP ${response.status}`);
29965
30175
  return true;
29966
30176
  }
29967
- 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;
29968
30178
  var init_control = __esm(async () => {
29969
30179
  await init_node_host();
29970
30180
  await init_files();
@@ -32893,8 +33103,10 @@ function activeContextBreakdown(state) {
32893
33103
  if (!topic)
32894
33104
  return;
32895
33105
  const messages = activeMessages(state);
32896
- const latest = latestUsageMessage(messages);
32897
- 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);
32898
33110
  const confirmed = latest?.usage.context ?? stored?.contextTokens;
32899
33111
  const contextWindow = latest?.usage.contextWindow ?? stored?.contextWindow;
32900
33112
  if (confirmed === undefined || !contextWindow)
@@ -34182,10 +34394,10 @@ function topicPickerHints(topicPickerRoot) {
34182
34394
  `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N new \xB7 Ctrl-D delete \xB7 ${exit}`,
34183
34395
  `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N new \xB7 Ctrl-D delete \xB7 ${shortExit}`,
34184
34396
  `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N new \xB7 Ctrl-D delete \xB7 ${exit}`,
34185
- `\u2191\u2193 select \xB7 Enter open \xB7 type to filter \xB7 Ctrl-N/D/P \xB7 ${shortExit}`,
34186
- "\u2191\u2193 \xB7 Enter \xB7 type to filter \xB7 Ctrl-N/D/P",
34187
- "type to filter \xB7 Ctrl-N/D/P",
34188
- "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"
34189
34401
  ];
34190
34402
  return [...new Set(candidates)].sort((a, b) => displayWidth(b) - displayWidth(a));
34191
34403
  }
@@ -35392,7 +35604,7 @@ class EmbeddedNegotiumClient {
35392
35604
  try {
35393
35605
  if (this.#startNode)
35394
35606
  this.#node = await startDefaultNode({ port: this.#port });
35395
- ensurePersonalGeneral(this.#userId);
35607
+ ensurePersonalGeneral(this.#userId, "terminal");
35396
35608
  } catch (error2) {
35397
35609
  this.#unsubscribe?.();
35398
35610
  this.#unsubscribe = null;
@@ -35776,7 +35988,7 @@ class RemoteNegotiumClient {
35776
35988
  }
35777
35989
  }
35778
35990
  async#openEventStream(after, signal) {
35779
- 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`, {
35780
35992
  signal,
35781
35993
  headers: {
35782
35994
  accept: "text/event-stream",
@@ -39862,7 +40074,7 @@ function startTelegramAdapter(opts) {
39862
40074
  if (initialForumChatId !== undefined && !forumMode) {
39863
40075
  logger.warn({ forumChatId: initialForumChatId }, "telegram adapter: forumChatId set but client lacks createForumTopic \u2014 forum mode disabled");
39864
40076
  }
39865
- const personalGeneral = ensurePersonalGeneral(userId);
40077
+ const personalGeneral = ensurePersonalGeneral(userId, "telegram");
39866
40078
  const byKey = new Map;
39867
40079
  const byTopic = new Map;
39868
40080
  const targetByQueryId = new Map;
@@ -40109,7 +40321,9 @@ function startTelegramAdapter(opts) {
40109
40321
  return true;
40110
40322
  }
40111
40323
  if (!store.isFlagSet(SURFACE_BACKFILL_FLAG)) {
40112
- 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
+ ];
40113
40327
  const moved = setTopicSurfaces(mappedIds, "telegram");
40114
40328
  store.setFlag(SURFACE_BACKFILL_FLAG);
40115
40329
  if (moved > 0) {
@@ -41358,45 +41572,101 @@ var init_join_status = __esm(() => {
41358
41572
  });
41359
41573
 
41360
41574
  // ../../adapters/otium/src/control-protocol.ts
41361
- 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";
41362
41576
 
41363
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
+ }
41364
41600
  function configureOtiumCentral(join42) {
41365
- joinConfig = join42;
41366
- resetPeerCentralCaches();
41601
+ cells.clear();
41602
+ if (join42)
41603
+ attachOtiumCentralCell(join42);
41367
41604
  }
41368
- function otiumCentralConfig() {
41369
- return joinConfig;
41605
+ function isOtiumCentralConfigured() {
41606
+ return cells.size > 0;
41370
41607
  }
41371
- function centralFetch(path, init) {
41372
- if (!joinConfig)
41373
- throw new Error("otium: join credentials missing");
41374
- return fetch(`${joinConfig.central}${path}`, {
41608
+ function centralFetch(cell, path, init) {
41609
+ return fetch(`${cell.join.central}${path}`, {
41375
41610
  ...init,
41376
41611
  headers: {
41377
- authorization: `Bearer ${joinConfig.secret}`,
41612
+ authorization: `Bearer ${cell.join.secret}`,
41378
41613
  "content-type": "application/json",
41379
41614
  ...init.headers ?? {}
41380
41615
  },
41381
41616
  signal: AbortSignal.timeout(5000)
41382
41617
  });
41383
41618
  }
41384
- async function listPeerNodes(opts = {}) {
41385
- if (!opts.fresh && nodesCache && Date.now() - nodesCache.at < NODES_CACHE_MS) {
41386
- 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;
41387
41622
  }
41388
- const response = await centralFetch("/peer/nodes", { method: "GET" });
41623
+ const response = await centralFetch(cell, "/peer/nodes", { method: "GET" });
41389
41624
  const body = await response.json();
41390
41625
  if (!response.ok || !body.ok || !Array.isArray(body.nodes)) {
41391
41626
  throw new Error(`otium: node discovery failed: ${body.error ?? response.status}`);
41392
41627
  }
41393
- nodesCache = { nodes: body.nodes, workspaceId: body.workspaceId ?? "", at: Date.now() };
41394
- 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;
41395
41631
  }
41396
- async function selfPeerNode() {
41397
- 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);
41398
41654
  return nodes.find((node) => node.self) ?? null;
41399
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
+ }
41400
41670
  async function resolvePeerNodeByCellId(cellId) {
41401
41671
  const find = (nodes) => nodes.find((node) => node.cellId === cellId) ?? null;
41402
41672
  const cached = find(await listPeerNodes());
@@ -41404,36 +41674,39 @@ async function resolvePeerNodeByCellId(cellId) {
41404
41674
  return cached;
41405
41675
  return find(await listPeerNodes({ fresh: true }));
41406
41676
  }
41407
- async function mintPeerToken(toCellId) {
41408
- 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);
41409
41682
  if (cached && cached.expiresAtMs - Date.now() > 30000)
41410
41683
  return cached.token;
41411
- const response = await centralFetch("/peer/token", {
41684
+ const response = await centralFetch(cell, "/peer/token", {
41412
41685
  method: "POST",
41413
- body: JSON.stringify({ toCellId })
41686
+ body: JSON.stringify({ toCellId: target.cellId })
41414
41687
  });
41415
41688
  const body = await response.json();
41416
41689
  if (!response.ok || !body.ok || !body.token) {
41417
41690
  throw new Error(`otium: peer token mint failed: ${body.error ?? response.status}`);
41418
41691
  }
41419
- tokenCache.set(toCellId, {
41692
+ cell.tokenCache.set(target.cellId, {
41420
41693
  token: body.token,
41421
41694
  expiresAtMs: Date.parse(body.expiresAt ?? "") || Date.now() + 60000
41422
41695
  });
41423
41696
  return body.token;
41424
41697
  }
41425
- async function verifyPeerToken(token) {
41426
- const cached = verifyCache.get(token);
41698
+ async function verifyAgainstCell(cell, token) {
41699
+ const cached = cell.verifyCache.get(token);
41427
41700
  if (cached && Date.now() - cached.at < VERIFY_CACHE_MS)
41428
41701
  return cached.verified;
41429
41702
  let response;
41430
41703
  try {
41431
- response = await centralFetch("/peer/verify", {
41704
+ response = await centralFetch(cell, "/peer/verify", {
41432
41705
  method: "POST",
41433
41706
  body: JSON.stringify({ token })
41434
41707
  });
41435
41708
  } catch (err2) {
41436
- logger.warn({ err: err2 }, "otium: central verify unreachable");
41709
+ logger.warn({ err: err2, cellId: cell.join.cellId }, "otium: central verify unreachable");
41437
41710
  return null;
41438
41711
  }
41439
41712
  const body = await response.json().catch(() => null);
@@ -41444,25 +41717,39 @@ async function verifyPeerToken(token) {
41444
41717
  fromCellId: body.fromCellId,
41445
41718
  fromNodeName: body.fromNodeName,
41446
41719
  fromIsPrimary: body.fromIsPrimary,
41447
- expiresAt: body.expiresAt
41720
+ expiresAt: body.expiresAt,
41721
+ viaCellId: cell.join.cellId
41448
41722
  };
41449
- verifyCache.set(token, { verified, at: Date.now() });
41450
- for (const [key, entry] of verifyCache) {
41723
+ cell.verifyCache.set(token, { verified, at: Date.now() });
41724
+ for (const [key, entry] of cell.verifyCache) {
41451
41725
  if (Date.now() - entry.at > VERIFY_CACHE_MS)
41452
- verifyCache.delete(key);
41726
+ cell.verifyCache.delete(key);
41453
41727
  }
41454
41728
  return verified;
41455
41729
  }
41456
- function resetPeerCentralCaches() {
41457
- nodesCache = null;
41458
- verifyCache.clear();
41459
- 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;
41460
41747
  }
41461
- 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;
41462
41749
  var init_central = __esm(async () => {
41463
41750
  await init_src();
41464
- verifyCache = new Map;
41465
- tokenCache = new Map;
41751
+ cells = new Map;
41752
+ rejectCache = new Map;
41466
41753
  });
41467
41754
 
41468
41755
  // ../../adapters/otium/src/protocol.ts
@@ -41537,7 +41824,7 @@ async function forwardCanonicalTool(capability, request) {
41537
41824
  if (!hub?.isPrimary || hub.self)
41538
41825
  return { error: "canonical hub is unavailable", status: 503 };
41539
41826
  try {
41540
- const peerToken = await mintPeerToken(hub.cellId);
41827
+ const peerToken = await mintPeerToken(hub);
41541
41828
  const response = await fetch(`${hub.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/canonical-mcp`, {
41542
41829
  method: "POST",
41543
41830
  headers: { authorization: `Bearer ${peerToken}`, "content-type": "application/json" },
@@ -41710,6 +41997,7 @@ __export(exports_join, {
41710
41997
  saveJoin: () => saveJoin,
41711
41998
  removeJoin: () => removeJoin,
41712
41999
  parseInviteCode: () => parseInviteCode,
42000
+ loadJoins: () => loadJoins,
41713
42001
  loadJoin: () => loadJoin,
41714
42002
  joinFilePath: () => joinFilePath,
41715
42003
  joinCredentialDigest: () => joinCredentialDigest,
@@ -41730,7 +42018,7 @@ import {
41730
42018
  rmSync as rmSync10,
41731
42019
  statSync as statSync20,
41732
42020
  unlinkSync as unlinkSync23,
41733
- writeFileSync as writeFileSync23
42021
+ writeFileSync as writeFileSync24
41734
42022
  } from "fs";
41735
42023
  import { dirname as dirname21, resolve as resolve24 } from "path";
41736
42024
  function joinFilePath() {
@@ -41811,7 +42099,7 @@ function withJoinCredentialLock(operation) {
41811
42099
  try {
41812
42100
  mkdirSync32(lockPath, { mode: 448 });
41813
42101
  created = true;
41814
- writeFileSync23(ownerPath, `${JSON.stringify(owner)}
42102
+ writeFileSync24(ownerPath, `${JSON.stringify(owner)}
41815
42103
  `, { mode: 384 });
41816
42104
  const ownerFd = openSync5(ownerPath, "r");
41817
42105
  try {
@@ -41881,82 +42169,60 @@ function normalizedJoin(join42) {
41881
42169
  function joinCredentialDigest(join42) {
41882
42170
  return createHash11("sha256").update(JSON.stringify(normalizedJoin(join42))).digest("base64url");
41883
42171
  }
41884
- function readPersistedJoin(path = joinFilePath()) {
42172
+ function readPersistedJoins(path = joinFilePath()) {
41885
42173
  if (!existsSync37(path))
41886
- return null;
42174
+ return [];
41887
42175
  const parsed = JSON.parse(readFileSync28(path, "utf-8"));
41888
42176
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
41889
42177
  throw new Error("persisted join credentials are not a JSON object");
41890
42178
  }
41891
- 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
+ });
41892
42188
  }
41893
42189
  function isJoinPersisted(join42) {
41894
42190
  try {
41895
- const persisted = readPersistedJoin();
41896
- return persisted !== null && joinsEqual(persisted, normalizedJoin(join42));
42191
+ const normalized = normalizedJoin(join42);
42192
+ return readPersistedJoins().some((persisted) => joinsEqual(persisted, normalized));
41897
42193
  } catch {
41898
42194
  return false;
41899
42195
  }
41900
42196
  }
41901
- 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) {
41902
42206
  const path = joinFilePath();
41903
42207
  const directory = dirname21(path);
41904
- const normalized = normalizedJoin(join42);
41905
- mkdirSync32(directory, { recursive: true });
41906
- if (existsSync37(path)) {
41907
- if (lstatSync(path).isSymbolicLink()) {
41908
- throw new Error(`refusing to replace symlinked Otium join file at ${path}`);
41909
- }
41910
- let existing = null;
41911
- try {
41912
- existing = readPersistedJoin(path);
41913
- } catch (error2) {
41914
- if (!options.replaceExisting) {
41915
- throw new Error(`existing Otium join file at ${path} is invalid; pass --replace to replace it`, { cause: error2 });
41916
- }
41917
- }
41918
- if (existing && joinsEqual(existing, normalized)) {
41919
- chmodSync7(path, 384);
41920
- const fileFd = openSync5(path, "r");
41921
- try {
41922
- fsyncSync2(fileFd);
41923
- } finally {
41924
- closeSync5(fileFd);
41925
- }
41926
- const directoryFd = openSync5(directory, "r");
41927
- try {
41928
- fsyncSync2(directoryFd);
41929
- } finally {
41930
- closeSync5(directoryFd);
41931
- }
41932
- return path;
41933
- }
41934
- if (!options.replaceExisting) {
41935
- throw new Error(`this node is already joined${existing ? ` as ${existing.cellId}` : " with an invalid join file"}; pass --replace to replace its credentials`);
41936
- }
41937
- }
41938
42208
  const temporaryPath = resolve24(directory, `.otium-join.json.${process.pid}.${randomUUID28()}.tmp`);
42209
+ const payload = { v: JOIN_FILE_VERSION, joins };
41939
42210
  let fd;
41940
42211
  try {
41941
42212
  fd = openSync5(temporaryPath, "wx", 384);
41942
- writeFileSync23(fd, `${JSON.stringify(normalized, null, 2)}
42213
+ writeFileSync24(fd, `${JSON.stringify(payload, null, 2)}
41943
42214
  `, "utf8");
41944
42215
  fsyncSync2(fd);
41945
42216
  closeSync5(fd);
41946
42217
  fd = undefined;
41947
- if (options.replaceExisting) {
42218
+ if (allowOverwrite) {
41948
42219
  renameSync16(temporaryPath, path);
41949
42220
  } else {
41950
42221
  linkSync(temporaryPath, path);
41951
42222
  unlinkSync23(temporaryPath);
41952
42223
  }
41953
42224
  chmodSync7(path, 384);
41954
- const directoryFd = openSync5(directory, "r");
41955
- try {
41956
- fsyncSync2(directoryFd);
41957
- } finally {
41958
- closeSync5(directoryFd);
41959
- }
42225
+ fsyncPath(directory);
41960
42226
  } catch (error2) {
41961
42227
  if (fd !== undefined)
41962
42228
  closeSync5(fd);
@@ -41966,10 +42232,43 @@ function saveJoinWhileLocked(join42, options = {}) {
41966
42232
  }
41967
42233
  return path;
41968
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
+ }
41969
42268
  function saveJoin(join42, options = {}) {
41970
42269
  return withJoinCredentialLock(() => saveJoinWhileLocked(join42, options));
41971
42270
  }
41972
- function removeJoin() {
42271
+ function removeJoin(cellId) {
41973
42272
  return withJoinCredentialLock(() => {
41974
42273
  const path = joinFilePath();
41975
42274
  if (!existsSync37(path))
@@ -41977,27 +42276,36 @@ function removeJoin() {
41977
42276
  if (lstatSync(path).isSymbolicLink()) {
41978
42277
  throw new Error(`refusing to remove symlinked Otium join file at ${path}`);
41979
42278
  }
41980
- unlinkSync23(path);
41981
- const directoryFd = openSync5(dirname21(path), "r");
41982
- try {
41983
- fsyncSync2(directoryFd);
41984
- } finally {
41985
- 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
+ }
41986
42292
  }
42293
+ unlinkSync23(path);
42294
+ fsyncPath(dirname21(path));
41987
42295
  return true;
41988
42296
  });
41989
42297
  }
41990
- function loadJoin() {
42298
+ function loadJoins() {
41991
42299
  const central = process.env.OTIUM_CENTRAL_URL?.trim();
41992
42300
  const cellId = process.env.OTIUM_CELL_ID?.trim();
41993
42301
  const secret = process.env.OTIUM_CELL_SECRET?.trim();
41994
42302
  const relay = process.env.OTIUM_RELAY_URL?.trim();
41995
42303
  if (central && cellId && secret) {
41996
42304
  try {
41997
- return normalizeJoin({ central, relay, cellId, secret });
42305
+ return [normalizeJoin({ central, relay, cellId, secret })];
41998
42306
  } catch (err2) {
41999
42307
  logger.warn({ err: err2 }, "otium: invalid OTIUM_CENTRAL_URL/OTIUM_CELL_ID/OTIUM_CELL_SECRET env");
42000
- return null;
42308
+ return [];
42001
42309
  }
42002
42310
  }
42003
42311
  if (central || cellId || secret) {
@@ -42005,18 +42313,18 @@ function loadJoin() {
42005
42313
  }
42006
42314
  const path = joinFilePath();
42007
42315
  if (!existsSync37(path))
42008
- return null;
42316
+ return [];
42009
42317
  try {
42010
- const parsed = JSON.parse(readFileSync28(path, "utf-8"));
42011
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
42012
- return null;
42013
- return normalizeJoin(parsed);
42318
+ return readPersistedJoins(path);
42014
42319
  } catch (err2) {
42015
42320
  logger.warn({ err: err2, path }, "otium: failed to read join file");
42016
- return null;
42321
+ return [];
42017
42322
  }
42018
42323
  }
42019
- 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;
42020
42328
  var init_join = __esm(async () => {
42021
42329
  await init_src();
42022
42330
  });
@@ -42170,7 +42478,7 @@ var init_runtime_bridge = __esm(async () => {
42170
42478
  return errorResult("Error: Hub node is no longer attached.");
42171
42479
  let token;
42172
42480
  try {
42173
- token = await mintPeerToken(hubNode.cellId);
42481
+ token = await mintPeerToken(hubNode);
42174
42482
  } catch (err2) {
42175
42483
  return errorResult(`Error: Failed to spawn on hub: ${err2.message}`);
42176
42484
  }
@@ -42210,7 +42518,7 @@ var init_runtime_bridge = __esm(async () => {
42210
42518
  return errorResult("Error: Hub node is no longer attached.");
42211
42519
  let token;
42212
42520
  try {
42213
- token = await mintPeerToken(hubNode.cellId);
42521
+ token = await mintPeerToken(hubNode);
42214
42522
  } catch (error2) {
42215
42523
  return errorResult(`Error: Failed to open hub question: ${error2.message}`);
42216
42524
  }
@@ -42266,7 +42574,7 @@ var init_runtime_bridge = __esm(async () => {
42266
42574
  if (!hubNode)
42267
42575
  return errorResult("Error: Hub node is no longer attached.");
42268
42576
  try {
42269
- const token = await mintPeerToken(hubNode.cellId);
42577
+ const token = await mintPeerToken(hubNode);
42270
42578
  const response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/self-config`, {
42271
42579
  method: "POST",
42272
42580
  headers: {
@@ -42297,7 +42605,7 @@ var init_runtime_bridge = __esm(async () => {
42297
42605
  return { ok: false, error: "hub node is no longer attached" };
42298
42606
  let token;
42299
42607
  try {
42300
- token = await mintPeerToken(hubNode.cellId);
42608
+ token = await mintPeerToken(hubNode);
42301
42609
  } catch (error2) {
42302
42610
  return { ok: false, error: `peer token mint failed: ${error2.message}` };
42303
42611
  }
@@ -42359,7 +42667,7 @@ var init_runtime_bridge = __esm(async () => {
42359
42667
  if (!hubNode)
42360
42668
  return { ok: false, error: "hub node is no longer attached" };
42361
42669
  try {
42362
- const token = await mintPeerToken(hubNode.cellId);
42670
+ const token = await mintPeerToken(hubNode);
42363
42671
  const form = new FormData;
42364
42672
  form.set("hostQueryId", request.bridge.hostQueryId);
42365
42673
  form.set("userId", request.userId);
@@ -42526,13 +42834,81 @@ var init_store2 = __esm(async () => {
42526
42834
  `);
42527
42835
  });
42528
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
+
42529
42905
  // ../../adapters/otium/src/session-bridge.ts
42530
42906
  function prunePendingRemoteAsks(now = Date.now()) {
42531
42907
  pruneRemoteAsks(now - PENDING_ASK_TTL_MS2);
42532
42908
  }
42533
42909
  async function postPeer(node, path, body) {
42534
42910
  try {
42535
- const token = await mintPeerToken(node.cellId);
42911
+ const token = await mintPeerToken(node);
42536
42912
  const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}${path}`, {
42537
42913
  method: "POST",
42538
42914
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -42569,7 +42945,7 @@ async function postPeerIdempotent(node, path, body) {
42569
42945
  }
42570
42946
  async function peerSupportsRemoteAsk(node) {
42571
42947
  try {
42572
- const token = await mintPeerToken(node.cellId);
42948
+ const token = await mintPeerToken(node);
42573
42949
  const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}/api/v1/peer/capabilities`, {
42574
42950
  headers: { authorization: `Bearer ${token}` },
42575
42951
  signal: AbortSignal.timeout(PEER_TIMEOUT_MS)
@@ -42580,26 +42956,50 @@ async function peerSupportsRemoteAsk(node) {
42580
42956
  return false;
42581
42957
  }
42582
42958
  }
42583
- async function findNode(nodeName) {
42584
- const find = (nodes) => nodes.find((node) => node.nodeName === nodeName) ?? null;
42585
- 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
+ };
42586
42983
  }
42587
- async function originLabel(args) {
42588
- const self = await selfPeerNode();
42984
+ async function originLabel(args, peer) {
42985
+ const self = await selfPeerNodeForCell(peer.viaCellId).catch(() => null);
42589
42986
  const local = args.fromTitle?.trim() || args.fromKey?.trim() || "peer";
42590
42987
  return self?.nodeName ? `${self.nodeName}/${local}` : local;
42591
42988
  }
42592
42989
  async function forward(args) {
42593
- const node = await findNode(args.toNode).catch(() => null);
42594
- 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)
42595
42995
  return { ok: false, error: `unknown remote node "${args.toNode}"` };
42596
- const self = await selfPeerNode().catch(() => null);
42996
+ const self = await selfPeerNodeForCell(node.viaCellId).catch(() => null);
42597
42997
  if (!self)
42598
42998
  return { ok: false, error: "local peer node is not attached" };
42599
42999
  if (!self.isPrimary && !node.isPrimary) {
42600
43000
  return { ok: false, error: "worker peer calls must target the primary hub" };
42601
43001
  }
42602
- const fromLabel = await originLabel(args);
43002
+ const fromLabel = await originLabel(args, node);
42603
43003
  const requestId = args.requestId;
42604
43004
  if (args.action === "ask") {
42605
43005
  if (!requestId || !args.fromTopicId || !args.fromKey || !args.message) {
@@ -42669,14 +43069,20 @@ async function forward(args) {
42669
43069
  ...args.sourceQueryId ? { sourceQueryId: args.sourceQueryId } : {}
42670
43070
  });
42671
43071
  }
42672
- async function sessions(userId, sourceQueryId) {
43072
+ async function sessions(userId, sourceQueryId, fromTopicId) {
42673
43073
  const nodes = await listPeerNodes().catch(() => []);
42674
- const self = nodes.find((node) => node.self);
42675
- if (!self)
43074
+ const selves = new Map(nodes.filter((node) => node.self).map((node) => [node.viaCellId, node]));
43075
+ if (selves.size === 0)
42676
43076
  return { ok: false, nodes: [] };
43077
+ const callerScope = fromTopicId ? getTopic(fromTopicId)?.surfaceScope ?? null : null;
42677
43078
  return {
42678
43079
  ok: true,
42679
- 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) => {
42680
43086
  const result = await postPeer(node, "/api/v1/peer/sessions", {
42681
43087
  v: PEER_PROTOCOL_VERSION,
42682
43088
  userId,
@@ -42714,7 +43120,7 @@ function flushPeerReplyOutbox() {
42714
43120
  toTopic: "",
42715
43121
  userId: pending2.user_id,
42716
43122
  fromTitle: pending2.source_title
42717
- });
43123
+ }, node);
42718
43124
  const result = await postPeer(node, "/api/v1/peer/reply", {
42719
43125
  v: PEER_PROTOCOL_VERSION,
42720
43126
  requestId: pending2.request_id,
@@ -42797,6 +43203,7 @@ var init_session_bridge = __esm(async () => {
42797
43203
  await init_central();
42798
43204
  init_protocol();
42799
43205
  await init_store2();
43206
+ await init_workspace_scope();
42800
43207
  PENDING_ASK_TTL_MS2 = 15 * 60 * 1000;
42801
43208
  otiumPeerSessionBridge = { forward, sessions, reply };
42802
43209
  });
@@ -42884,7 +43291,7 @@ function startPeerSessionBridgeIpc(bridge) {
42884
43291
  return Response.json(await bridge.forward(payload.args));
42885
43292
  }
42886
43293
  if (payload.action === "sessions") {
42887
- return Response.json(await bridge.sessions(payload.userId, payload.sourceQueryId));
43294
+ return Response.json(await bridge.sessions(payload.userId, payload.sourceQueryId, payload.fromTopicId));
42888
43295
  }
42889
43296
  if (payload.action === "reply") {
42890
43297
  return Response.json(await bridge.reply(payload.route, payload.sourceTitle, payload.replyText, payload.kind));
@@ -43501,14 +43908,14 @@ import {
43501
43908
  existsSync as existsSync38,
43502
43909
  fsyncSync as fsyncSync3,
43503
43910
  linkSync as linkSync2,
43504
- mkdirSync as mkdirSync34,
43911
+ mkdirSync as mkdirSync35,
43505
43912
  openSync as openSync6,
43506
- readFileSync as readFileSync29,
43913
+ readFileSync as readFileSync30,
43507
43914
  renameSync as renameSync17,
43508
43915
  unlinkSync as unlinkSync24,
43509
- writeFileSync as writeFileSync24
43916
+ writeFileSync as writeFileSync26
43510
43917
  } from "fs";
43511
- import { dirname as dirname22, resolve as resolve25 } from "path";
43918
+ import { dirname as dirname23, resolve as resolve26 } from "path";
43512
43919
  function parseEnrollmentInvite(code) {
43513
43920
  let parsed;
43514
43921
  try {
@@ -43529,14 +43936,14 @@ function parseEnrollmentInvite(code) {
43529
43936
  return { v: 2, central, token };
43530
43937
  }
43531
43938
  function pendingEnrollmentPath() {
43532
- return resolve25(DATA_DIR, "otium-enrollment-pending.json");
43939
+ return resolve26(DATA_DIR, "otium-enrollment-pending.json");
43533
43940
  }
43534
43941
  function isEnrollmentPending(invite) {
43535
43942
  const path = pendingEnrollmentPath();
43536
43943
  if (!existsSync38(path))
43537
43944
  return false;
43538
43945
  try {
43539
- const saved = JSON.parse(readFileSync29(path, "utf8"));
43946
+ const saved = JSON.parse(readFileSync30(path, "utf8"));
43540
43947
  return saved.central === invite.central && saved.token === invite.token;
43541
43948
  } catch {
43542
43949
  return false;
@@ -43545,7 +43952,7 @@ function isEnrollmentPending(invite) {
43545
43952
  function loadOrCreatePending(invite, nodeName) {
43546
43953
  const path = pendingEnrollmentPath();
43547
43954
  if (existsSync38(path)) {
43548
- const saved = JSON.parse(readFileSync29(path, "utf8"));
43955
+ const saved = JSON.parse(readFileSync30(path, "utf8"));
43549
43956
  if (saved.central === invite.central && saved.token === invite.token)
43550
43957
  return saved;
43551
43958
  throw new Error(`another Otium enrollment is pending at ${path}`);
@@ -43558,12 +43965,12 @@ function loadOrCreatePending(invite, nodeName) {
43558
43965
  publicKey: pair.publicKey.export({ format: "der", type: "spki" }).toString("base64url"),
43559
43966
  ...nodeName ? { nodeName } : {}
43560
43967
  };
43561
- mkdirSync34(dirname22(path), { recursive: true });
43562
- 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`);
43563
43970
  let fd;
43564
43971
  try {
43565
43972
  fd = openSync6(temporaryPath, "wx", 384);
43566
- writeFileSync24(fd, `${JSON.stringify(pending2, null, 2)}
43973
+ writeFileSync26(fd, `${JSON.stringify(pending2, null, 2)}
43567
43974
  `, "utf8");
43568
43975
  fsyncSync3(fd);
43569
43976
  closeSync6(fd);
@@ -43571,7 +43978,7 @@ function loadOrCreatePending(invite, nodeName) {
43571
43978
  linkSync2(temporaryPath, path);
43572
43979
  unlinkSync24(temporaryPath);
43573
43980
  chmodSync8(path, 384);
43574
- const directoryFd = openSync6(dirname22(path), "r");
43981
+ const directoryFd = openSync6(dirname23(path), "r");
43575
43982
  try {
43576
43983
  fsyncSync3(directoryFd);
43577
43984
  } finally {
@@ -43583,7 +43990,7 @@ function loadOrCreatePending(invite, nodeName) {
43583
43990
  if (existsSync38(temporaryPath))
43584
43991
  unlinkSync24(temporaryPath);
43585
43992
  if (error2.code === "EEXIST" && existsSync38(path)) {
43586
- const saved = JSON.parse(readFileSync29(path, "utf8"));
43993
+ const saved = JSON.parse(readFileSync30(path, "utf8"));
43587
43994
  if (saved.central === invite.central && saved.token === invite.token)
43588
43995
  return saved;
43589
43996
  }
@@ -43593,12 +44000,12 @@ function loadOrCreatePending(invite, nodeName) {
43593
44000
  }
43594
44001
  function replacePendingEnrollment(pending2) {
43595
44002
  const path = pendingEnrollmentPath();
43596
- const directory = dirname22(path);
43597
- 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`);
43598
44005
  let fd;
43599
44006
  try {
43600
44007
  fd = openSync6(temporaryPath, "wx", 384);
43601
- writeFileSync24(fd, `${JSON.stringify(pending2, null, 2)}
44008
+ writeFileSync26(fd, `${JSON.stringify(pending2, null, 2)}
43602
44009
  `, "utf8");
43603
44010
  fsyncSync3(fd);
43604
44011
  closeSync6(fd);
@@ -43620,7 +44027,7 @@ function replacePendingEnrollment(pending2) {
43620
44027
  }
43621
44028
  function recordClaimedCredential(pending2, join43) {
43622
44029
  withJoinCredentialLock(() => {
43623
- const current3 = JSON.parse(readFileSync29(pendingEnrollmentPath(), "utf8"));
44030
+ const current3 = JSON.parse(readFileSync30(pendingEnrollmentPath(), "utf8"));
43624
44031
  if (current3.central !== pending2.central || current3.token !== pending2.token || current3.idempotencyKey !== pending2.idempotencyKey || current3.publicKey !== pending2.publicKey) {
43625
44032
  throw new Error("pending Otium enrollment changed while its claim was in flight");
43626
44033
  }
@@ -43699,7 +44106,7 @@ async function claimEnrollment(invite, nodeName) {
43699
44106
  function commitEnrollment(join43, options = {}) {
43700
44107
  return withJoinCredentialLock(() => {
43701
44108
  const pendingPath = pendingEnrollmentPath();
43702
- const pending2 = existsSync38(pendingPath) ? JSON.parse(readFileSync29(pendingPath, "utf8")) : null;
44109
+ const pending2 = existsSync38(pendingPath) ? JSON.parse(readFileSync30(pendingPath, "utf8")) : null;
43703
44110
  const digest = joinCredentialDigest(join43);
43704
44111
  if (pending2 && (!pending2.claimed || pending2.claimed.digest !== digest || pending2.claimed.cellId !== join43.cellId)) {
43705
44112
  throw new Error(`pending Otium enrollment at ${pendingPath} does not match these credentials`);
@@ -43711,7 +44118,7 @@ function commitEnrollment(join43, options = {}) {
43711
44118
  if (!pending2)
43712
44119
  return path;
43713
44120
  unlinkSync24(pendingPath);
43714
- const directoryFd = openSync6(dirname22(pendingPath), "r");
44121
+ const directoryFd = openSync6(dirname23(pendingPath), "r");
43715
44122
  try {
43716
44123
  fsyncSync3(directoryFd);
43717
44124
  } finally {
@@ -43750,6 +44157,8 @@ async function forwardGatewayRequest(req, options) {
43750
44157
  target.search = url.search;
43751
44158
  const headers = new Headers(req.headers);
43752
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");
43753
44162
  headers.delete("transfer-encoding");
43754
44163
  headers.delete("content-length");
43755
44164
  headers.delete("host");
@@ -43764,7 +44173,7 @@ async function forwardGatewayRequest(req, options) {
43764
44173
  signal: req.signal
43765
44174
  }));
43766
44175
  }
43767
- 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";
43768
44177
  var init_gateway_forward = __esm(async () => {
43769
44178
  await init_src();
43770
44179
  });
@@ -43797,7 +44206,7 @@ function checkProtocol(body) {
43797
44206
  return null;
43798
44207
  }
43799
44208
  async function requirePeer(req) {
43800
- if (!otiumCentralConfig()) {
44209
+ if (!isOtiumCentralConfigured()) {
43801
44210
  return { ok: false, response: jsonError2("multi-node is disabled", 403) };
43802
44211
  }
43803
44212
  const token = bearer(req);
@@ -43811,8 +44220,22 @@ async function requirePeer(req) {
43811
44220
  function requirePrimaryOrigin(peer) {
43812
44221
  return peer.verified.fromIsPrimary ? null : jsonError2("only the workspace hub may call this endpoint", 403);
43813
44222
  }
43814
- function peerAddressable(topic) {
43815
- 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);
43816
44239
  }
43817
44240
  function localCapabilities() {
43818
44241
  const agents = SUPPORTED_AGENTS.map((kind) => {
@@ -43882,8 +44305,8 @@ async function handleAbort(req) {
43882
44305
  const toTopic = str(body, "toTopic");
43883
44306
  if (!userId || !toTopic)
43884
44307
  return jsonError2("userId and toTopic are required", 400);
43885
- const topic = getTopicByNameForUser(toTopic, userId);
43886
- if (!topic || !peerAddressable(topic)) {
44308
+ const topic = peerTopicByName(toTopic, userId, peer);
44309
+ if (!topic || !peerAddressable(topic, peer)) {
43887
44310
  return jsonError2(`shared topic "${toTopic}" not found on this node`, 404);
43888
44311
  }
43889
44312
  appendJsonlEntry(sessionInboxPath(userId, topic.id), {
@@ -43924,8 +44347,8 @@ async function handleTell(req) {
43924
44347
  if (depth > MAX_TELL_DEPTH) {
43925
44348
  return jsonError2(`tell depth limit exceeded (max ${MAX_TELL_DEPTH})`, 400);
43926
44349
  }
43927
- const topic = getTopicByNameForUser(toTopic, userId);
43928
- if (!topic || !peerAddressable(topic)) {
44350
+ const topic = peerTopicByName(toTopic, userId, peer);
44351
+ if (!topic || !peerAddressable(topic, peer)) {
43929
44352
  return jsonError2(`shared topic "${toTopic}" not found on this node`, 404);
43930
44353
  }
43931
44354
  const claim = claimInboundPeerMessage({
@@ -43982,7 +44405,7 @@ async function handleSessions(req) {
43982
44405
  const userId = str(body, "userId");
43983
44406
  if (!userId)
43984
44407
  return jsonError2("userId is required", 400);
43985
- 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));
43986
44409
  const titleCounts = new Map;
43987
44410
  for (const topic of topics) {
43988
44411
  const normalized = topic.title.toLowerCase();
@@ -44030,8 +44453,8 @@ async function handleAsk(req) {
44030
44453
  if (!Number.isInteger(fromDepth) || fromDepth < 0) {
44031
44454
  return jsonError2("fromDepth must be a non-negative integer", 400);
44032
44455
  }
44033
- const topic = getTopicByNameForUser(toTopic, userId);
44034
- if (!topic || !peerAddressable(topic)) {
44456
+ const topic = peerTopicByName(toTopic, userId, peer);
44457
+ if (!topic || !peerAddressable(topic, peer)) {
44035
44458
  return jsonError2(`shared topic "${toTopic}" not found on this node`, 404);
44036
44459
  }
44037
44460
  if (!topic.agent)
@@ -44151,7 +44574,7 @@ async function handleOtiumPeerRequest(req) {
44151
44574
  const url = new URL(req.url);
44152
44575
  const path = url.pathname;
44153
44576
  if (path === "/ready" && req.method === "GET") {
44154
- if (!otiumCentralConfig())
44577
+ if (!isOtiumCentralConfigured())
44155
44578
  return null;
44156
44579
  return Response.json({ ok: true });
44157
44580
  }
@@ -44170,7 +44593,9 @@ async function handleOtiumPeerRequest(req) {
44170
44593
  return jsonError2("canonical Negotium node is unavailable", 503);
44171
44594
  }
44172
44595
  return forwardGatewayRequest(req, {
44173
- 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()
44174
44599
  });
44175
44600
  }
44176
44601
  if (req.method === "GET") {
@@ -44215,41 +44640,89 @@ var init_peer_server = __esm(async () => {
44215
44640
  init_protocol();
44216
44641
  await init_session_bridge();
44217
44642
  await init_store2();
44643
+ await init_workspace_scope();
44218
44644
  RUNTIME_VERSION = NEGOTIUM_VERSION;
44219
44645
  });
44220
44646
 
44221
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
+ }
44222
44701
  function startOtiumNodeRuntime(options) {
44223
44702
  const { join: join43 } = options;
44224
- configureOtiumCentral(join43);
44225
- const unregisterRuntimeBridge = registerPeerRuntimeBridge(otiumPeerRuntimeBridge);
44226
- const unregisterSessionBridge = registerPeerSessionBridge(otiumPeerSessionBridge);
44227
- const sessionBridgeIpc = startPeerSessionBridgeIpc(otiumPeerSessionBridge);
44228
- const canonicalMcpBridge = startCanonicalMcpBridge();
44229
- const stopPeerReplyOutbox = startPeerReplyOutboxWorker();
44230
- failInterruptedRemoteAskCallbacks().then((failedAsks) => {
44231
- if (failedAsks > 0) {
44232
- logger.warn({ failedAsks }, "otium: failed remote asks interrupted by previous process");
44233
- }
44234
- });
44235
- const uninstallFileHooks = installPeerFileHooks();
44236
- const unsubscribeTopicCleanup = runtimeBus().subscribe((event) => {
44237
- if (event.type !== "topic-deleted")
44238
- return;
44239
- const removed = cleanupPeerStateForLocalTopic(event.topicId);
44240
- if (removed.inboxRequests + removed.remoteAsks > 0) {
44241
- logger.info({ topicId: event.topicId, ...removed }, "otium: removed peer state for deleted local topic");
44242
- }
44243
- });
44703
+ attachOtiumCentralCell(join43);
44704
+ const releaseGlobals = acquireGlobalOtiumServices();
44244
44705
  let stopped = false;
44245
44706
  logger.info({ central: join43.central, cellId: join43.cellId }, "otium: worker mode enabled");
44246
- selfPeerNode().then((self) => {
44707
+ mountedScopes.set(join43.cellId, cachedSurfaceScope(join43));
44708
+ refreshDefaultSurfaceScope();
44709
+ selfPeerNodeForCell(join43.cellId).then((self) => {
44247
44710
  if (self) {
44248
44711
  logger.info({ nodeName: self.nodeName, baseUrl: self.baseUrl }, "otium: attached to workspace");
44249
44712
  }
44250
44713
  }).catch((err2) => {
44251
44714
  logger.warn({ err: err2 }, "otium: self check against central failed (will retry per request)");
44252
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
+ });
44253
44726
  return {
44254
44727
  name: "otium",
44255
44728
  join: join43,
@@ -44257,14 +44730,10 @@ function startOtiumNodeRuntime(options) {
44257
44730
  if (stopped)
44258
44731
  return;
44259
44732
  stopped = true;
44260
- unsubscribeTopicCleanup();
44261
- unregisterRuntimeBridge();
44262
- unregisterSessionBridge();
44263
- sessionBridgeIpc.stop();
44264
- canonicalMcpBridge.stop();
44265
- stopPeerReplyOutbox();
44266
- uninstallFileHooks();
44267
- configureOtiumCentral(null);
44733
+ mountedScopes.delete(join43.cellId);
44734
+ refreshDefaultSurfaceScope();
44735
+ detachOtiumCentralCell(join43.cellId);
44736
+ releaseGlobals();
44268
44737
  }
44269
44738
  };
44270
44739
  }
@@ -44302,7 +44771,7 @@ function startOtiumAdapter(options) {
44302
44771
  }
44303
44772
  };
44304
44773
  }
44305
- var otiumAdapter;
44774
+ var globalServices = null, mountedScopes, otiumAdapter;
44306
44775
  var init_src8 = __esm(async () => {
44307
44776
  await init_src();
44308
44777
  await init_canonical_mcp_bridge();
@@ -44314,6 +44783,7 @@ var init_src8 = __esm(async () => {
44314
44783
  init_session_bridge_ipc();
44315
44784
  await init_store2();
44316
44785
  init_tunnel_client();
44786
+ await init_workspace_scope();
44317
44787
  await init_central();
44318
44788
  await init_enrollment();
44319
44789
  await init_join();
@@ -44323,6 +44793,8 @@ var init_src8 = __esm(async () => {
44323
44793
  await init_runtime_bridge();
44324
44794
  await init_store2();
44325
44795
  init_tunnel_client();
44796
+ await init_workspace_scope();
44797
+ mountedScopes = new Map;
44326
44798
  otiumAdapter = defineNegotiumAdapter({
44327
44799
  name: "otium",
44328
44800
  capabilities: {
@@ -44342,12 +44814,59 @@ var init_src8 = __esm(async () => {
44342
44814
  // ../../adapters/otium/src/node-runtime.ts
44343
44815
  var exports_node_runtime = {};
44344
44816
  __export(exports_node_runtime, {
44817
+ reconcileOtiumWorkspaces: () => reconcileOtiumWorkspaces,
44818
+ mountedOtiumWorkspaces: () => mountedOtiumWorkspaces,
44345
44819
  mountConfiguredOtiumNodeRuntime: () => mountConfiguredOtiumNodeRuntime,
44346
44820
  handleOtiumAdapterControlRequest: () => handleOtiumAdapterControlRequest,
44821
+ detachOtiumWorkspace: () => detachOtiumWorkspace,
44822
+ attachOtiumWorkspace: () => attachOtiumWorkspace,
44347
44823
  OTIUM_ADAPTER_CONTROL_PREFIX: () => OTIUM_ADAPTER_CONTROL_PREFIX,
44348
44824
  OTIUM_ADAPTER_CONTROL_HEADER: () => OTIUM_ADAPTER_CONTROL_HEADER,
44349
44825
  MAX_PEER_REQUEST_BODY_BYTES: () => MAX_PEER_REQUEST_BODY_BYTES
44350
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
+ }
44351
44870
  async function handleOtiumAdapterControlRequest(req) {
44352
44871
  const url = new URL(req.url);
44353
44872
  if (!url.pathname.startsWith(`${OTIUM_ADAPTER_CONTROL_PREFIX}/`))
@@ -44356,6 +44875,25 @@ async function handleOtiumAdapterControlRequest(req) {
44356
44875
  return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
44357
44876
  }
44358
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
+ }
44359
44897
  const peerUrl = new URL(req.url);
44360
44898
  peerUrl.pathname = peerPath;
44361
44899
  const headers = new Headers(req.headers);
@@ -44364,29 +44902,72 @@ async function handleOtiumAdapterControlRequest(req) {
44364
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 });
44365
44903
  }
44366
44904
  function mountConfiguredOtiumNodeRuntime() {
44367
- const join43 = loadJoin();
44368
- if (!join43)
44905
+ const joins = loadJoins();
44906
+ if (joins.length === 0)
44369
44907
  return null;
44370
- const runtime2 = startOtiumNodeRuntime({ join: join43 });
44908
+ for (const join43 of joins)
44909
+ attachOtiumWorkspace(join43);
44371
44910
  registerNodeRequestHandler("otium-adapter-control", handleOtiumAdapterControlRequest);
44372
44911
  let stopped = false;
44373
44912
  return {
44374
- ...runtime2,
44913
+ name: "otium",
44914
+ join: joins[0],
44375
44915
  stop() {
44376
44916
  if (stopped)
44377
44917
  return;
44378
44918
  stopped = true;
44379
44919
  unregisterNodeRequestHandler("otium-adapter-control");
44380
- runtime2.stop();
44920
+ for (const cellId of [...mounted.keys()])
44921
+ detachOtiumWorkspace(cellId);
44381
44922
  }
44382
44923
  };
44383
44924
  }
44925
+ var mounted;
44384
44926
  var init_node_runtime = __esm(async () => {
44385
44927
  await init_src();
44386
44928
  await init_src8();
44387
44929
  await init_join();
44388
44930
  await init_peer_server();
44389
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();
44390
44971
  });
44391
44972
 
44392
44973
  // ../../adapters/otium/src/join-cli.ts
@@ -44480,6 +45061,12 @@ async function joinCommand(args) {
44480
45061
  } finally {
44481
45062
  configureOtiumCentral(null);
44482
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
+ }
44483
45070
  console.log("\nnext: `negotium-otium serve` (mounts the otium peer routes automatically)");
44484
45071
  }
44485
45072
  var init_join_cli = __esm(async () => {
@@ -44494,9 +45081,15 @@ __export(exports_sidecar, {
44494
45081
  runOtiumSidecar: () => runOtiumSidecar,
44495
45082
  proxyOtiumPeerRequest: () => proxyOtiumPeerRequest
44496
45083
  });
45084
+ function isPublicPeerPath(pathname) {
45085
+ return pathname === "/ready" || pathname.startsWith("/api/v1/peer/");
45086
+ }
44497
45087
  async function proxyOtiumPeerRequest(req, dependencies = {}) {
44498
45088
  const inspectNode = dependencies.inspectNode ?? inspectNodeDaemon;
44499
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
+ }
44500
45093
  const status = await inspectNode();
44501
45094
  if (!status.running || !status.info) {
44502
45095
  return Response.json({ ok: false, error: "canonical Negotium node is unavailable" }, { status: 503 });
@@ -44507,6 +45100,7 @@ async function proxyOtiumPeerRequest(req, dependencies = {}) {
44507
45100
  target.search = source.search;
44508
45101
  const headers = new Headers(req.headers);
44509
45102
  headers.set(OTIUM_ADAPTER_CONTROL_HEADER, NODE_CONTROL_TOKEN);
45103
+ headers.set(OTIUM_RELAYED_HEADER, "1");
44510
45104
  try {
44511
45105
  const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
44512
45106
  headers.delete("transfer-encoding");
@@ -44563,8 +45157,8 @@ async function runOtiumSidecar(options) {
44563
45157
  }) : null;
44564
45158
  tunnel?.start();
44565
45159
  let resolveCompleted;
44566
- const completed = new Promise((resolve26) => {
44567
- resolveCompleted = resolve26;
45160
+ const completed = new Promise((resolve27) => {
45161
+ resolveCompleted = resolve27;
44568
45162
  });
44569
45163
  onShutdown("otium-sidecar-server", 130, () => server?.stop(true));
44570
45164
  onShutdown("otium-sidecar-tunnel", 120, () => tunnel?.stop());
@@ -44690,16 +45284,28 @@ async function runOtiumCli(args = process.argv.slice(2)) {
44690
45284
  break;
44691
45285
  }
44692
45286
  case "leave": {
44693
- if (commandArgs.length > 0)
44694
- 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
+ }
44695
45291
  if (process.env.OTIUM_CENTRAL_URL || process.env.OTIUM_CELL_ID || process.env.OTIUM_CELL_SECRET) {
44696
45292
  throw new Error("Otium join is configured by environment; remove OTIUM_CENTRAL_URL, OTIUM_CELL_ID, and OTIUM_CELL_SECRET to disconnect");
44697
45293
  }
44698
- const { loadJoin: loadJoin2, removeJoin: removeJoin2 } = await init_join().then(() => exports_join);
44699
- if (!loadJoin2())
45294
+ const { loadJoins: loadJoins2, removeJoin: removeJoin2 } = await init_join().then(() => exports_join);
45295
+ const joins = loadJoins2();
45296
+ if (joins.length === 0)
44700
45297
  throw new Error("not joined to an Otium workspace");
44701
- removeJoin2();
44702
- 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");
44703
45309
  break;
44704
45310
  }
44705
45311
  case "serve": {
@@ -44722,8 +45328,9 @@ async function runOtiumCli(args = process.argv.slice(2)) {
44722
45328
  " serve [--port <port>] [--relay <url>]",
44723
45329
  " run peer routes and an outbound relay tunnel",
44724
45330
  "",
44725
- "Publish a topic to the workspace with /public in that topic; the hub",
44726
- "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."
44727
45334
  ].join(`
44728
45335
  `));
44729
45336
  if (command && command !== "help" && command !== "--help")
@@ -44766,7 +45373,7 @@ __export(exports_topics, {
44766
45373
  topicsCommand: () => topicsCommand
44767
45374
  });
44768
45375
  function topicsCommand() {
44769
- const topics = getVisibleTopics();
45376
+ const topics = getVisibleTopics({ surface: "terminal" });
44770
45377
  if (topics.length === 0) {
44771
45378
  console.log("no topics yet - start `negotium` to create one in Terminal");
44772
45379
  return;
@@ -45265,6 +45872,16 @@ function renderCliHelp() {
45265
45872
  // ../cli/src/main.ts
45266
45873
  var [, , rawCommand, ...args] = process.argv;
45267
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
+ }
45268
45885
  function numericOption(values, name, fallback) {
45269
45886
  const prefix = `--${name}=`;
45270
45887
  const parsed = Number.parseInt(values.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? "", 10);
@@ -45290,7 +45907,7 @@ async function runCanonicalNode(port) {
45290
45907
  });
45291
45908
  console.log(`negotium node listening on 127.0.0.1:${node.port} (ctrl-c to stop)`);
45292
45909
  await node.completed;
45293
- await new Promise((resolve26) => setImmediate(resolve26));
45910
+ await new Promise((resolve27) => setImmediate(resolve27));
45294
45911
  process.exit(0);
45295
45912
  }
45296
45913
  async function stopAdapter(name) {
@@ -45322,8 +45939,7 @@ switch (command) {
45322
45939
  }
45323
45940
  case "serve": {
45324
45941
  if (args[0] === "otium") {
45325
- const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
45326
- await runOtiumCli2(["serve", ...args.slice(1)]);
45942
+ await runOtium(["serve", ...args.slice(1)]);
45327
45943
  break;
45328
45944
  }
45329
45945
  await runCanonicalNode(numericOption(args, "port", 7777));
@@ -45407,11 +46023,10 @@ switch (command) {
45407
46023
  break;
45408
46024
  }
45409
46025
  case "otium": {
45410
- const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
45411
46026
  if (args[0] === "serve") {
45412
46027
  process.stderr.write("warning: `negotium otium serve` is deprecated; use `negotium serve otium`\n");
45413
46028
  }
45414
- await runOtiumCli2(args);
46029
+ await runOtium(args);
45415
46030
  break;
45416
46031
  }
45417
46032
  default: {
@@ -45421,4 +46036,4 @@ switch (command) {
45421
46036
  }
45422
46037
  }
45423
46038
 
45424
- //# debugId=57EA2CF6F8F9DA8164756E2164756E21
46039
+ //# debugId=EDB732002F3171B764756E2164756E21