negotium 0.2.24 → 0.2.26

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 (31) hide show
  1. package/dist/agent-helpers.js +82 -29
  2. package/dist/agent-helpers.js.map +5 -5
  3. package/dist/hosted-agent.js +2 -2
  4. package/dist/hosted-agent.js.map +2 -2
  5. package/dist/main.js +167 -364
  6. package/dist/main.js.map +26 -27
  7. package/dist/mcp-factories.js +84 -30
  8. package/dist/mcp-factories.js.map +6 -6
  9. package/dist/registry.js +2 -2
  10. package/dist/registry.js.map +2 -2
  11. package/dist/runtime/src/index.ts +3 -7
  12. package/dist/runtime/src/mcp/session-comm/default-host.ts +6 -1
  13. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +17 -1
  14. package/dist/runtime/src/mcp/session-comm/topics.ts +23 -1
  15. package/dist/runtime/src/node-host.ts +2 -3
  16. package/dist/runtime/src/storage/api-topics.ts +164 -52
  17. package/dist/runtime/src/storage/storage-public.ts +0 -1
  18. package/dist/runtime/src/topics/create.ts +12 -6
  19. package/dist/runtime/src/topics/derive.ts +20 -11
  20. package/dist/runtime/src/types/api.ts +8 -4
  21. package/dist/runtime/src/version.ts +1 -1
  22. package/dist/storage.js +92 -38
  23. package/dist/storage.js.map +3 -3
  24. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +7 -0
  25. package/dist/types/packages/core/src/storage/api-topics.d.ts +34 -19
  26. package/dist/types/packages/core/src/storage/storage-public.d.ts +1 -1
  27. package/dist/types/packages/core/src/topics/derive.d.ts +13 -4
  28. package/dist/types/packages/core/src/types/api.d.ts +8 -4
  29. package/dist/types/packages/core/src/version.d.ts +1 -1
  30. package/package.json +1 -1
  31. package/dist/runtime/src/application/switch-topic-access-mode.ts +0 -110
@@ -9,14 +9,31 @@ import type {
9
9
  AiMode,
10
10
  ParticipantDto,
11
11
  SubagentReportMode,
12
- TopicAccessMode,
13
12
  TopicDto,
14
13
  TopicKind,
14
+ TopicSurface,
15
15
  TopicVisibility,
16
16
  } from "#types/api";
17
17
 
18
18
  const DEFAULT_AGENT_ROOM_AGENT: AgentKind = "maestro";
19
19
 
20
+ /**
21
+ * Surface used when a caller does not name one — and the value every existing
22
+ * row is backfilled with on first boot after the surface migration.
23
+ *
24
+ * Hosts that only ever serve one surface declare it once in their environment
25
+ * (`NEGOTIUM_DEFAULT_SURFACE=otium` on the Otium hub and worker); a developer
26
+ * Mac leaves it unset and gets `terminal`, with the telegram adapter
27
+ * reclassifying its own mapped rooms afterwards.
28
+ */
29
+ export function defaultTopicSurface(): TopicSurface {
30
+ return normalizeTopicSurface(process.env.NEGOTIUM_DEFAULT_SURFACE);
31
+ }
32
+
33
+ export function normalizeTopicSurface(value: unknown): TopicSurface {
34
+ return value === "telegram" || value === "otium" || value === "terminal" ? value : "terminal";
35
+ }
36
+
20
37
  function tableColumns(table: string): Set<string> {
21
38
  const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
22
39
  return new Set(rows.map((row) => row.name));
@@ -219,7 +236,7 @@ function initializeApiTopicsSchema(): void {
219
236
  is_fork INTEGER NOT NULL DEFAULT 0 CHECK (is_fork IN (0,1)),
220
237
  is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
221
238
  visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
222
- access_mode TEXT NOT NULL DEFAULT 'private' CHECK (access_mode IN ('private','shared')),
239
+ surface TEXT NOT NULL DEFAULT 'terminal' CHECK (surface IN ('terminal','telegram','otium')),
223
240
  browser_profile TEXT NOT NULL DEFAULT 'default',
224
241
  browser_profile_owner TEXT,
225
242
  session_id TEXT,
@@ -288,7 +305,7 @@ function initializeApiTopicsSchema(): void {
288
305
  db.query(
289
306
  `INSERT INTO api_topics_next
290
307
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
291
- created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,access_mode,session_id)
308
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,session_id)
292
309
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
293
310
  ).run(
294
311
  String(row.id),
@@ -307,7 +324,7 @@ function initializeApiTopicsSchema(): void {
307
324
  Number(row.is_fork ?? 0) !== 0 ? 1 : 0,
308
325
  Number(row.is_subagent ?? 0) !== 0 ? 1 : 0,
309
326
  row.visibility === "hidden" ? "hidden" : "visible",
310
- row.access_mode === "shared" ? "shared" : "private",
327
+ row.surface === undefined ? defaultTopicSurface() : normalizeTopicSurface(row.surface),
311
328
  typeof row.session_id === "string" ? row.session_id : null,
312
329
  );
313
330
  }
@@ -373,8 +390,18 @@ function initializeApiTopicsSchema(): void {
373
390
  if (!tableColumns("api_topics").has("visibility")) {
374
391
  db.exec("ALTER TABLE api_topics ADD COLUMN visibility TEXT NOT NULL DEFAULT 'visible'");
375
392
  }
376
- if (!tableColumns("api_topics").has("access_mode")) {
377
- db.exec("ALTER TABLE api_topics ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'private'");
393
+ if (!tableColumns("api_topics").has("surface")) {
394
+ db.exec("ALTER TABLE api_topics ADD COLUMN surface TEXT NOT NULL DEFAULT 'terminal'");
395
+ }
396
+ // `access_mode` was replaced by `surface`: a topic is reachable from Otium
397
+ // because it lives there, not because a flag was flipped (S-4). Dropping the
398
+ // column removes the second, now-contradictory source of truth.
399
+ if (tableColumns("api_topics").has("access_mode")) {
400
+ try {
401
+ db.exec("ALTER TABLE api_topics DROP COLUMN access_mode");
402
+ } catch (err) {
403
+ logger.warn({ err }, "api_topics: could not drop the retired access_mode column");
404
+ }
378
405
  }
379
406
  if (!tableColumns("api_topics").has("browser_profile")) {
380
407
  db.exec("ALTER TABLE api_topics ADD COLUMN browser_profile TEXT NOT NULL DEFAULT 'default'");
@@ -401,9 +428,80 @@ function initializeApiTopicsSchema(): void {
401
428
  )
402
429
  WHERE browser_profile_owner IS NULL
403
430
  `);
431
+ backfillTopicSurfaces();
404
432
  db.exec(
405
433
  "CREATE INDEX IF NOT EXISTS idx_api_topics_last_message ON api_topics(last_message_at DESC)",
406
434
  );
435
+ db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_surface ON api_topics(surface)");
436
+ }
437
+
438
+ const SURFACE_BACKFILL_MIGRATION = "api_topics_surface_backfill_20260808";
439
+
440
+ /**
441
+ * One-time classification of pre-surface topics.
442
+ *
443
+ * Every row predates the column, so there is no per-row evidence in this store
444
+ * to distinguish surfaces — the host declares it (`NEGOTIUM_DEFAULT_SURFACE`).
445
+ * The telegram adapter owns the second pass: its chat↔topic mapping lives in a
446
+ * different database file, so only it can reclassify the rooms it created.
447
+ *
448
+ * Names are unique per surface from now on, so collapsing three namespaces into
449
+ * one can produce duplicates. Rather than failing the boot, the oldest room
450
+ * keeps the name and the rest are suffixed — a rename is recoverable, a node
451
+ * that refuses to start is not.
452
+ */
453
+ function backfillTopicSurfaces(): void {
454
+ db.exec(`
455
+ CREATE TABLE IF NOT EXISTS api_schema_migrations (
456
+ key TEXT PRIMARY KEY,
457
+ applied_at TEXT NOT NULL
458
+ )
459
+ `);
460
+ const applied = db
461
+ .query("SELECT key FROM api_schema_migrations WHERE key = ?")
462
+ .get(SURFACE_BACKFILL_MIGRATION);
463
+ if (applied) return;
464
+
465
+ const surface = defaultTopicSurface();
466
+ db.transaction(() => {
467
+ db.query("UPDATE api_topics SET surface = ?").run(surface);
468
+ renameSurfaceTitleCollisions();
469
+ db.query("INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)").run(
470
+ SURFACE_BACKFILL_MIGRATION,
471
+ new Date().toISOString(),
472
+ );
473
+ })();
474
+ logger.info({ surface }, "api_topics: surface backfilled");
475
+ }
476
+
477
+ /** Suffix duplicate `(surface, kind, title)` rows so the new uniqueness rule holds. */
478
+ function renameSurfaceTitleCollisions(): void {
479
+ const rows = db
480
+ .query<{ id: string; title: string; kind: string; surface: string }, []>(
481
+ "SELECT id, title, kind, surface FROM api_topics ORDER BY created_at ASC, rowid ASC",
482
+ )
483
+ .all();
484
+ const taken = new Set<string>();
485
+ const update = db.query("UPDATE api_topics SET title = ? WHERE id = ?");
486
+ for (const row of rows) {
487
+ const key = (title: string) => [row.surface, row.kind, normalizedTitle(title)].join("\u0000");
488
+ if (!taken.has(key(row.title))) {
489
+ taken.add(key(row.title));
490
+ continue;
491
+ }
492
+ let suffix = 2;
493
+ let candidate = `${row.title} (${suffix})`;
494
+ while (taken.has(key(candidate))) {
495
+ suffix += 1;
496
+ candidate = `${row.title} (${suffix})`;
497
+ }
498
+ taken.add(key(candidate));
499
+ update.run(candidate, row.id);
500
+ logger.warn(
501
+ { topicId: row.id, surface: row.surface, from: row.title, to: candidate },
502
+ "api_topics: renamed a duplicate title for surface-scoped uniqueness",
503
+ );
504
+ }
407
505
  }
408
506
 
409
507
  registerStorageSchemaInitializer(initializeApiTopicsSchema, 20);
@@ -426,7 +524,7 @@ export interface TopicRow {
426
524
  is_subagent: number;
427
525
  subagent_report_mode: string | null;
428
526
  visibility: string | null;
429
- access_mode: string | null;
527
+ surface: string | null;
430
528
  browser_profile_owner: string | null;
431
529
  session_id: string | null;
432
530
  }
@@ -525,19 +623,10 @@ function rowToDto(
525
623
  }
526
624
  : {}),
527
625
  visibility: normalizeTopicVisibility(r.visibility),
528
- accessMode: normalizeTopicAccessMode(r.access_mode),
626
+ surface: normalizeTopicSurface(r.surface),
529
627
  };
530
628
  }
531
629
 
532
- export function normalizeTopicAccessMode(value: unknown): TopicAccessMode {
533
- return value === "shared" ? "shared" : "private";
534
- }
535
-
536
- /** Otium and other non-local adapters may only address shared topics. */
537
- export function isTopicShared(topic: Pick<TopicDto, "accessMode">): boolean {
538
- return topic.accessMode === "shared";
539
- }
540
-
541
630
  export function normalizeTopicVisibility(value: unknown): TopicVisibility {
542
631
  return value === "hidden" ? "hidden" : "visible";
543
632
  }
@@ -643,7 +732,7 @@ export function upsertTopic(t: TopicDto): void {
643
732
  db.query(
644
733
  `INSERT INTO api_topics
645
734
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
646
- created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,access_mode,
735
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
647
736
  subagent_report_mode)
648
737
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
649
738
  ON CONFLICT(id) DO UPDATE SET
@@ -662,7 +751,7 @@ export function upsertTopic(t: TopicDto): void {
662
751
  is_fork = excluded.is_fork,
663
752
  is_subagent = excluded.is_subagent,
664
753
  visibility = excluded.visibility,
665
- access_mode = excluded.access_mode,
754
+ surface = excluded.surface,
666
755
  subagent_report_mode = excluded.subagent_report_mode`,
667
756
  ).run(
668
757
  t.id,
@@ -681,7 +770,11 @@ export function upsertTopic(t: TopicDto): void {
681
770
  t.isFork ? 1 : 0,
682
771
  t.isSubagent ? 1 : 0,
683
772
  normalizeTopicVisibility(t.visibility),
684
- normalizeTopicAccessMode(t.accessMode),
773
+ // A DTO without a surface means "wherever this host puts rooms", not
774
+ // "terminal": embedding hosts (Otium) build topic literals by hand and
775
+ // never name one, so defaulting to the literal would file every hub room
776
+ // on the wrong surface.
777
+ normalizeTopicSurface(t.surface ?? defaultTopicSurface()),
685
778
  t.subagentReportMode ?? "auto",
686
779
  );
687
780
  db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
@@ -706,35 +799,20 @@ export function upsertTopic(t: TopicDto): void {
706
799
  }
707
800
 
708
801
  /**
709
- * Apply one access mode to a set of topics as a single all-or-nothing write.
710
- *
711
- * A partial write is not a cosmetic glitch here: a public parent left with
712
- * private subagent children (or the reverse) is exactly the half-exposed state
713
- * the access-mode cascade exists to prevent, so every row commits or none do.
802
+ * List topics, optionally restricted to one surface.
714
803
  *
715
- * Deliberately a narrow UPDATE instead of a loop over {@link upsertTopic}.
716
- * That helper opens its own transaction, and the node:sqlite shim in
717
- * `sqlite.ts` emulates transactions with bare BEGIN/COMMIT rather than
718
- * savepoints — nesting one inside an outer transaction would fail outright.
719
- * It also rewrites participants and browser-profile ownership, none of which
720
- * an access-mode change should touch.
804
+ * The filter lives here rather than in each adapter so a forgotten call site
805
+ * cannot leak a telegram room into the terminal picker; adapters pass their own
806
+ * surface and get a closed world back.
721
807
  */
722
- export function setTopicAccessModes(
723
- topicIds: readonly string[],
724
- accessMode: TopicAccessMode,
725
- ): void {
726
- if (topicIds.length === 0) return;
727
- const normalized = normalizeTopicAccessMode(accessMode);
728
- db.transaction(() => {
729
- const update = db.query("UPDATE api_topics SET access_mode = ? WHERE id = ?");
730
- for (const topicId of topicIds) update.run(normalized, topicId);
731
- })();
732
- }
733
-
734
- export function listTopics(): TopicDto[] {
735
- const rows = db
736
- .query("SELECT * FROM api_topics ORDER BY last_message_at DESC")
737
- .all() as TopicRow[];
808
+ export function listTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
809
+ const rows = (
810
+ opts.surface
811
+ ? db
812
+ .query("SELECT * FROM api_topics WHERE surface = ? ORDER BY last_message_at DESC")
813
+ .all(normalizeTopicSurface(opts.surface))
814
+ : db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
815
+ ) as TopicRow[];
738
816
  const participants = getAllTopicParticipants();
739
817
  // Batch-load tell grants once: per-row queries would make every listTopics()
740
818
  // call O(N) extra statements as subagent counts grow.
@@ -816,12 +894,17 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
816
894
  return r ? rowToDto(r) : null;
817
895
  }
818
896
 
897
+ /**
898
+ * Titles are unique **per surface**, not per node: `otium` may exist once on
899
+ * the terminal, once on telegram and once on the Otium hub.
900
+ */
819
901
  export function findTopicTitleConflict(
820
902
  title: string,
821
903
  kind: TopicKind,
822
- opts: { excludeTopicId?: string } = {},
904
+ opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
823
905
  ): TopicDto | null {
824
906
  const wanted = normalizedTitle(title);
907
+ const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
825
908
  const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
826
909
  if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
827
910
  const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
@@ -830,8 +913,8 @@ export function findTopicTitleConflict(
830
913
  if (general) return rowToDto(general);
831
914
  }
832
915
 
833
- const params: string[] = [wanted];
834
- let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ?";
916
+ const params: string[] = [wanted, surface];
917
+ let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ?";
835
918
  if (kind !== "manager") {
836
919
  sql += " AND (kind = ? OR id = ?)";
837
920
  params.push(kind, GENERAL_TOPIC_ID);
@@ -845,8 +928,30 @@ export function findTopicTitleConflict(
845
928
  return row ? rowToDto(row) : null;
846
929
  }
847
930
 
931
+ /**
932
+ * Move topics onto a surface. Used by adapters that own a classification the
933
+ * canonical store cannot derive on its own (the telegram chat↔topic mapping
934
+ * lives in that adapter's database, not this one).
935
+ */
936
+ export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurface): number {
937
+ if (topicIds.length === 0) return 0;
938
+ const normalized = normalizeTopicSurface(surface);
939
+ let changed = 0;
940
+ db.transaction(() => {
941
+ const update = db.query("UPDATE api_topics SET surface = ? WHERE id = ? AND surface != ?");
942
+ for (const topicId of topicIds) {
943
+ changed += Number(update.run(normalized, topicId, normalized).changes ?? 0);
944
+ }
945
+ })();
946
+ return changed;
947
+ }
948
+
848
949
  /** Look up a topic by title, restricted to topics where `userId` participates. */
849
- export function getTopicByNameForUser(title: string, userId: string): TopicDto | null {
950
+ export function getTopicByNameForUser(
951
+ title: string,
952
+ userId: string,
953
+ opts: { surface?: TopicSurface } = {},
954
+ ): TopicDto | null {
850
955
  const trimmed = title.trim();
851
956
  const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
852
957
  const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
@@ -857,11 +962,18 @@ export function getTopicByNameForUser(title: string, userId: string): TopicDto |
857
962
  WHERE LOWER(t.title) = LOWER(?)
858
963
  AND t.id != ?
859
964
  AND t.visibility != 'hidden'
965
+ AND (? IS NULL OR t.surface = ?)
860
966
  AND EXISTS (
861
967
  SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
862
968
  )`,
863
969
  )
864
- .all(requestedTitle, GENERAL_TOPIC_ID, userId) as TopicRow[];
970
+ .all(
971
+ requestedTitle,
972
+ GENERAL_TOPIC_ID,
973
+ opts.surface ?? null,
974
+ opts.surface ?? null,
975
+ userId,
976
+ ) as TopicRow[];
865
977
  const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
866
978
  return matches.length === 1 ? rowToDto(matches[0]!) : null;
867
979
  }
@@ -93,7 +93,6 @@ export type {
93
93
  MessageDto,
94
94
  ParticipantDto,
95
95
  ResponsePolicy,
96
- TopicAccessMode,
97
96
  TopicDto,
98
97
  TopicKind,
99
98
  TopicVisibility,
@@ -14,13 +14,15 @@ import { FALLBACK_AGENT, resolveTopicWorkspaceDir } from "#platform/config";
14
14
  import { RESERVED_TOPIC_NAMES } from "#platform/constants";
15
15
  import { logger } from "#platform/logger";
16
16
  import {
17
+ defaultTopicSurface,
17
18
  findTopicTitleConflict,
18
19
  normalizeTopicKind,
19
20
  normalizeTopicState,
21
+ normalizeTopicSurface,
20
22
  upsertTopic,
21
23
  } from "#storage/api-topics";
22
24
  import { type AgentKind, type EffortLevel, isAgentKind } from "#types";
23
- import type { TopicAccessMode, TopicDto } from "#types/api";
25
+ import type { TopicDto, TopicSurface } from "#types/api";
24
26
 
25
27
  // Agent rooms default to the node-level FALLBACK_AGENT (env), not a hardcoded
26
28
  // backend — a node without DeepSeek auth can still default to claude/codex.
@@ -43,8 +45,11 @@ export interface RegisterTopicOptions {
43
45
  model?: string;
44
46
  effort?: EffortLevel;
45
47
  description?: string;
46
- /** Local topics default to private; Otium publication must be explicit. */
47
- accessMode?: TopicAccessMode;
48
+ /**
49
+ * Which product surface owns the room. Callers that represent a user-facing
50
+ * adapter must pass their own surface; unset falls back to the host default.
51
+ */
52
+ surface?: TopicSurface;
48
53
  }
49
54
 
50
55
  /**
@@ -62,9 +67,10 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
62
67
  if (requestedKind === "manager") {
63
68
  throw new TopicValidationError("Manager rooms are system-managed");
64
69
  }
65
- const conflict = findTopicTitleConflict(title, requestedKind);
70
+ const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
71
+ const conflict = findTopicTitleConflict(title, requestedKind, { surface });
66
72
  if (conflict) {
67
- throw new TopicValidationError(`A topic named "${title}" already exists`);
73
+ throw new TopicValidationError(`A topic named "${title}" already exists on ${surface}`);
68
74
  }
69
75
 
70
76
  const rawAgent = opts.agent;
@@ -106,7 +112,7 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
106
112
  defaultEffort: defaultEffort ?? "medium",
107
113
  aiMode,
108
114
  participants: [{ userId: opts.userId, role: "owner" }],
109
- accessMode: opts.accessMode ?? "private",
115
+ surface,
110
116
  createdAt: now,
111
117
  lastMessageAt: now,
112
118
  };
@@ -24,6 +24,7 @@ import {
24
24
  } from "#storage/api-messages";
25
25
  import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
26
26
  import {
27
+ defaultTopicSurface,
27
28
  findTopicTitleConflict,
28
29
  getTopic,
29
30
  getTopicSessionId,
@@ -53,15 +54,20 @@ import {
53
54
  shouldCompactForkEntries,
54
55
  } from "#topics/session";
55
56
  import type { AgentKind } from "#types";
56
- import type { TopicDto } from "#types/api";
57
+ import type { TopicDto, TopicSurface } from "#types/api";
57
58
 
58
- export function getTopics(): TopicDto[] {
59
- return listTopics().filter((topic) => !isLegacySharedGeneral(topic.id));
59
+ export function getTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
60
+ return listTopics(opts).filter((topic) => !isLegacySharedGeneral(topic.id));
60
61
  }
61
62
 
62
- /** Topics adapters may show in lists and selection UIs. */
63
- export function getVisibleTopics(): TopicDto[] {
64
- return getTopics()
63
+ /**
64
+ * Topics adapters may show in lists and selection UIs.
65
+ *
66
+ * Callers that represent one product surface pass it, so a telegram room never
67
+ * appears in the terminal picker and vice versa (S-6).
68
+ */
69
+ export function getVisibleTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
70
+ return getTopics(opts)
65
71
  .filter(isTopicVisible)
66
72
  .map((topic) => {
67
73
  if (!topic.agent) return topic;
@@ -100,9 +106,10 @@ function nextDerivedTopicTitle(
100
106
  sourceTitle: string,
101
107
  kind: TopicDto["kind"],
102
108
  suffix: "fork" | "spawn" | "agent",
109
+ surface?: TopicSurface,
103
110
  ): string {
104
111
  const visibleTitles = new Set(
105
- listTopics()
112
+ listTopics(surface ? { surface } : {})
106
113
  .filter((topic) => topic.kind === kind)
107
114
  .map((topic) => topic.title.toLowerCase()),
108
115
  );
@@ -264,8 +271,9 @@ async function createDerivedTopicImpl(
264
271
  ]
265
272
  : [{ userId, role: "owner" as const }];
266
273
  const kind = topic.kind ?? inferTopicKind(topic);
267
- const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix);
268
- const conflict = findTopicTitleConflict(title, kind);
274
+ const surface = topic.surface ?? defaultTopicSurface();
275
+ const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface);
276
+ const conflict = findTopicTitleConflict(title, kind, { surface });
269
277
  if (conflict) {
270
278
  logger.info(
271
279
  { sourceTopicId, title, kind, conflictTopicId: conflict.id },
@@ -291,7 +299,8 @@ async function createDerivedTopicImpl(
291
299
  isFork: copyHistory,
292
300
  ...(subagent ? { isSubagent: true } : {}),
293
301
  visibility: topic.visibility,
294
- accessMode: topic.accessMode,
302
+ // A derived room lives on the same surface as the room it came from.
303
+ surface,
295
304
  };
296
305
 
297
306
  let sessionId: string | undefined;
@@ -443,7 +452,7 @@ async function createDerivedTopicImpl(
443
452
  ) {
444
453
  throw new TopicDeriveBusyError("Source topic changed while deriving; try again");
445
454
  }
446
- const transactionalConflict = findTopicTitleConflict(title, kind);
455
+ const transactionalConflict = findTopicTitleConflict(title, kind, { surface });
447
456
  if (transactionalConflict) throw new TopicTitleConflictError(title);
448
457
  upsertTopic(derived);
449
458
  if (subagent) {
@@ -13,8 +13,12 @@
13
13
  /** Agent identifier — one of the supported AI provider backends. */
14
14
  export type AgentKind = "maestro" | "claude" | "codex";
15
15
  export type TopicKind = "channel" | "agent" | "manager";
16
- /** Adapter access boundary for a user-facing topic. */
17
- export type TopicAccessMode = "private" | "shared";
16
+ /**
17
+ * The one product surface a topic lives on. A topic belongs to exactly one
18
+ * surface for its whole life; names are unique per surface, not per node.
19
+ */
20
+ export type TopicSurface = "terminal" | "telegram" | "otium";
21
+ export const TOPIC_SURFACES: readonly TopicSurface[] = ["terminal", "telegram", "otium"];
18
22
  /** Whether adapters may expose a topic in user-facing discovery surfaces. */
19
23
  export type TopicVisibility = "visible" | "hidden";
20
24
  export type ResponsePolicy = "off" | "mention" | "always";
@@ -124,8 +128,8 @@ export interface TopicDto {
124
128
  subagentReportMode?: SubagentReportMode;
125
129
  /** Hidden topics remain executable/addressable by id but stay out of adapter pickers. */
126
130
  visibility?: TopicVisibility;
127
- /** Private stays on local adapters; shared may be exposed through Otium too. */
128
- accessMode?: TopicAccessMode;
131
+ /** The product surface that owns this topic (terminal / telegram / otium). */
132
+ surface?: TopicSurface;
129
133
  /** Stable execution placement. Absent means the hub runs this topic locally. */
130
134
  executionNode?: {
131
135
  nodeId: string;
@@ -1 +1 @@
1
- export const NEGOTIUM_VERSION = "0.2.24";
1
+ export const NEGOTIUM_VERSION = "0.2.26";