negotium 0.2.23 → 0.2.25

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 (48) hide show
  1. package/dist/agent-helpers.js +84 -30
  2. package/dist/agent-helpers.js.map +6 -6
  3. package/dist/background-bash.js +2 -1
  4. package/dist/background-bash.js.map +3 -3
  5. package/dist/browser-runtime.js +2 -1
  6. package/dist/browser-runtime.js.map +3 -3
  7. package/dist/{chunk-rhp26p2c.js → chunk-1y2xw0xe.js} +2 -1
  8. package/dist/{chunk-rhp26p2c.js.map → chunk-1y2xw0xe.js.map} +3 -3
  9. package/dist/hosted-agent.js +3 -2
  10. package/dist/hosted-agent.js.map +4 -4
  11. package/dist/main.js +224 -366
  12. package/dist/main.js.map +27 -28
  13. package/dist/mcp-factories.js +86 -31
  14. package/dist/mcp-factories.js.map +7 -7
  15. package/dist/prompts.js +2 -1
  16. package/dist/prompts.js.map +3 -3
  17. package/dist/query-runtime.js +2 -1
  18. package/dist/query-runtime.js.map +3 -3
  19. package/dist/registry.js +3 -3
  20. package/dist/registry.js.map +2 -2
  21. package/dist/rollout.js +1 -1
  22. package/dist/runtime/src/index.ts +3 -7
  23. package/dist/runtime/src/mcp/session-comm/default-host.ts +6 -1
  24. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +17 -1
  25. package/dist/runtime/src/mcp/session-comm/topics.ts +23 -1
  26. package/dist/runtime/src/node-host.ts +4 -4
  27. package/dist/runtime/src/platform/config.ts +12 -0
  28. package/dist/runtime/src/storage/api-topics.ts +160 -52
  29. package/dist/runtime/src/storage/storage-public.ts +0 -1
  30. package/dist/runtime/src/topics/create.ts +12 -6
  31. package/dist/runtime/src/topics/derive.ts +20 -11
  32. package/dist/runtime/src/types/api.ts +8 -4
  33. package/dist/runtime/src/version.ts +1 -1
  34. package/dist/runtime-helpers.js +2 -1
  35. package/dist/runtime-helpers.js.map +3 -3
  36. package/dist/storage.js +92 -38
  37. package/dist/storage.js.map +3 -3
  38. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +7 -0
  39. package/dist/types/packages/core/src/platform/config.d.ts +12 -0
  40. package/dist/types/packages/core/src/storage/api-topics.d.ts +34 -19
  41. package/dist/types/packages/core/src/storage/storage-public.d.ts +1 -1
  42. package/dist/types/packages/core/src/topics/derive.d.ts +13 -4
  43. package/dist/types/packages/core/src/types/api.d.ts +8 -4
  44. package/dist/types/packages/core/src/version.d.ts +1 -1
  45. package/dist/vault.js +2 -1
  46. package/dist/vault.js.map +3 -3
  47. package/package.json +1 -1
  48. package/dist/runtime/src/application/switch-topic-access-mode.ts +0 -110
@@ -5,6 +5,8 @@ export interface SessionTopicRow {
5
5
  agent: string | null;
6
6
  sessionId: string | null;
7
7
  description: string | null;
8
+ /** Product surface the topic lives on; rows from other surfaces are not addressable. */
9
+ surface?: string | null;
8
10
  }
9
11
 
10
12
  export interface SessionTopicEntry<TAgent extends string = string> {
@@ -36,6 +38,11 @@ export interface SessionTargetCatalogHost<TAgent extends string = string> {
36
38
  readonly listRows: () => SessionTopicRow[];
37
39
  readonly currentTopicId?: string;
38
40
  readonly currentTopicName?: string;
41
+ /**
42
+ * Surface of the calling topic. Sessions only converse within their own
43
+ * surface: terminal with terminal, telegram with telegram, otium with otium.
44
+ */
45
+ readonly currentSurface?: string;
39
46
  readonly isAgent: (value: string | null) => value is TAgent;
40
47
  }
41
48
 
@@ -51,7 +58,16 @@ export function createSessionTargetCatalog<TAgent extends string = string>(
51
58
  const { currentTopicId, currentTopicName, isAgent, listRows } = host;
52
59
 
53
60
  function listTargets(): SessionTarget<TAgent>[] {
54
- const eligibleRows = listRows().filter((row) => row.kind !== "manager");
61
+ // Read through `host` rather than a destructured copy so a host can expose
62
+ // it as a lazy getter and keep DB access out of module import time.
63
+ const currentSurface = host.currentSurface;
64
+ const eligibleRows = listRows().filter(
65
+ (row) =>
66
+ row.kind !== "manager" &&
67
+ // Rows predating the surface column report null and stay addressable
68
+ // from the local surface rather than vanishing mid-migration.
69
+ (!currentSurface || (row.surface ?? currentSurface) === currentSurface),
70
+ );
55
71
  const titleCounts = new Map<string, number>();
56
72
  const qualifiedCounts = new Map<string, number>();
57
73
  for (const row of eligibleRows) {
@@ -78,6 +78,7 @@ function sessionTargetRows(): Array<{
78
78
  agent: string | null;
79
79
  session_id: string | null;
80
80
  description: string | null;
81
+ surface: string | null;
81
82
  }> {
82
83
  if (!existsSync(SESSIONS_DB)) return [];
83
84
  try {
@@ -91,10 +92,11 @@ function sessionTargetRows(): Array<{
91
92
  agent: string | null;
92
93
  session_id: string | null;
93
94
  description: string | null;
95
+ surface: string | null;
94
96
  },
95
97
  string
96
98
  >(
97
- `SELECT t.id, t.title, t.kind, t.agent, t.session_id, t.description
99
+ `SELECT t.id, t.title, t.kind, t.agent, t.session_id, t.description, t.surface
98
100
  FROM api_topics t
99
101
  INNER JOIN topic_members m ON m.topic_id = t.id
100
102
  WHERE m.user_id = ?`,
@@ -117,9 +119,28 @@ export function getTopicsForUser(): { [name: string]: TopicEntry } {
117
119
  return sessionTargetCatalog.getTopics();
118
120
  }
119
121
 
122
+ /**
123
+ * Surface of the room this MCP server is serving. Read once from the canonical
124
+ * store: session-comm only ever addresses sessions on the same surface.
125
+ */
126
+ let cachedSessionSurface: { value: string | undefined } | null = null;
127
+
128
+ function currentSessionSurface(): string | undefined {
129
+ if (cachedSessionSurface) return cachedSessionSurface.value;
130
+ const rows = sessionTargetRows();
131
+ const mine = currentTopicId
132
+ ? rows.find((row) => row.id === currentTopicId)
133
+ : rows.find((row) => row.title === currentTopic);
134
+ cachedSessionSurface = { value: mine?.surface ?? undefined };
135
+ return cachedSessionSurface.value;
136
+ }
137
+
120
138
  const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
121
139
  currentTopicId,
122
140
  currentTopicName: currentTopic,
141
+ get currentSurface() {
142
+ return currentSessionSurface();
143
+ },
123
144
  isAgent: isAgentKind,
124
145
  listRows: () =>
125
146
  sessionTargetRows().map((row) => ({
@@ -129,6 +150,7 @@ const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
129
150
  agent: row.agent,
130
151
  sessionId: row.session_id,
131
152
  description: row.description,
153
+ surface: row.surface,
132
154
  })),
133
155
  });
134
156
 
@@ -11,7 +11,6 @@ export {
11
11
  submitRuntimeGatewayTurn,
12
12
  } from "#application/submit-runtime-gateway-turn";
13
13
  export { submitUserMessage } from "#application/submit-user-message";
14
- export { switchTopicAccessMode } from "#application/switch-topic-access-mode";
15
14
  export { switchTopicEffort } from "#application/switch-topic-effort";
16
15
  export { switchTopicModel } from "#application/switch-topic-model";
17
16
  export { TopicServiceError, topicService } from "#application/topic-service";
@@ -29,6 +28,7 @@ export {
29
28
  DATA_DIR,
30
29
  NEGOTIUM_PORT,
31
30
  NODE_CONTROL_TOKEN,
31
+ NODE_ID,
32
32
  RUN_DIR,
33
33
  STATE_DIR,
34
34
  WORKSPACE_DIR,
@@ -65,8 +65,8 @@ export type { FileHooks, UploadAccess } from "#runtime/file-hooks";
65
65
  export { setFileHooks } from "#runtime/file-hooks";
66
66
  export { startSessionInboxWorker } from "#runtime/inbox";
67
67
  export { startAiTurn, startDurableTurnRequestWorker } from "#runtime/turn-runner";
68
- export { listApiMessages } from "#storage/api-messages";
69
- export { getTopic, isTopicShared } from "#storage/api-topics";
68
+ export { appendApiMessage, listApiMessages } from "#storage/api-messages";
69
+ export { getTopic, upsertTopic } from "#storage/api-topics";
70
70
  export type { StoredRuntimeEvent } from "#storage/runtime-events";
71
71
  export {
72
72
  latestRuntimeEventSeq,
@@ -85,5 +85,5 @@ export {
85
85
  export { ensurePersonalGeneral } from "#topics/personal-general";
86
86
  export { compactTopicSession } from "#topics/session";
87
87
  export type { AgentKind } from "#types";
88
- export type { AttachmentDto, TopicDto } from "#types/api";
88
+ export type { AttachmentDto, TopicDto, TopicSurface } from "#types/api";
89
89
  export { NEGOTIUM_VERSION } from "#version";
@@ -360,6 +360,18 @@ export const NODE_CONTROL_TOKEN = loadOrCreateLocalSecret(
360
360
  "NEGOTIUM_CONTROL_TOKEN",
361
361
  "node-control-token",
362
362
  );
363
+ /**
364
+ * Stable identity of this node's store, minted once and kept across restarts.
365
+ *
366
+ * Deliberately not a secret: it is published in the gateway health response so a
367
+ * host can tell "the node I recorded a mapping against" apart from "whatever is
368
+ * answering on that port today". Without it, pointing a host at a different node
369
+ * makes every existing mapping look like a topic whose owner withdrew it, and the
370
+ * same topics come back as duplicate rooms. It shares the secrets directory only
371
+ * because that is already where per-install state that must survive restarts
372
+ * lives; the stricter file mode costs nothing.
373
+ */
374
+ export const NODE_ID = loadOrCreateLocalSecret("NEGOTIUM_NODE_ID", "node-id");
363
375
  export const VAULT_MASTER_KEY = loadOrCreateLocalSecret(
364
376
  "NEGOTIUM_VAULT_MASTER_KEY",
365
377
  "vault-master-key",
@@ -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)}`;
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,7 @@ 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
+ normalizeTopicSurface(t.surface),
685
774
  t.subagentReportMode ?? "auto",
686
775
  );
687
776
  db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
@@ -706,35 +795,20 @@ export function upsertTopic(t: TopicDto): void {
706
795
  }
707
796
 
708
797
  /**
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.
798
+ * List topics, optionally restricted to one surface.
714
799
  *
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.
800
+ * The filter lives here rather than in each adapter so a forgotten call site
801
+ * cannot leak a telegram room into the terminal picker; adapters pass their own
802
+ * surface and get a closed world back.
721
803
  */
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[];
804
+ export function listTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
805
+ const rows = (
806
+ opts.surface
807
+ ? db
808
+ .query("SELECT * FROM api_topics WHERE surface = ? ORDER BY last_message_at DESC")
809
+ .all(normalizeTopicSurface(opts.surface))
810
+ : db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
811
+ ) as TopicRow[];
738
812
  const participants = getAllTopicParticipants();
739
813
  // Batch-load tell grants once: per-row queries would make every listTopics()
740
814
  // call O(N) extra statements as subagent counts grow.
@@ -816,12 +890,17 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
816
890
  return r ? rowToDto(r) : null;
817
891
  }
818
892
 
893
+ /**
894
+ * Titles are unique **per surface**, not per node: `otium` may exist once on
895
+ * the terminal, once on telegram and once on the Otium hub.
896
+ */
819
897
  export function findTopicTitleConflict(
820
898
  title: string,
821
899
  kind: TopicKind,
822
- opts: { excludeTopicId?: string } = {},
900
+ opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
823
901
  ): TopicDto | null {
824
902
  const wanted = normalizedTitle(title);
903
+ const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
825
904
  const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
826
905
  if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
827
906
  const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
@@ -830,8 +909,8 @@ export function findTopicTitleConflict(
830
909
  if (general) return rowToDto(general);
831
910
  }
832
911
 
833
- const params: string[] = [wanted];
834
- let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ?";
912
+ const params: string[] = [wanted, surface];
913
+ let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ?";
835
914
  if (kind !== "manager") {
836
915
  sql += " AND (kind = ? OR id = ?)";
837
916
  params.push(kind, GENERAL_TOPIC_ID);
@@ -845,8 +924,30 @@ export function findTopicTitleConflict(
845
924
  return row ? rowToDto(row) : null;
846
925
  }
847
926
 
927
+ /**
928
+ * Move topics onto a surface. Used by adapters that own a classification the
929
+ * canonical store cannot derive on its own (the telegram chat↔topic mapping
930
+ * lives in that adapter's database, not this one).
931
+ */
932
+ export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurface): number {
933
+ if (topicIds.length === 0) return 0;
934
+ const normalized = normalizeTopicSurface(surface);
935
+ let changed = 0;
936
+ db.transaction(() => {
937
+ const update = db.query("UPDATE api_topics SET surface = ? WHERE id = ? AND surface != ?");
938
+ for (const topicId of topicIds) {
939
+ changed += Number(update.run(normalized, topicId, normalized).changes ?? 0);
940
+ }
941
+ })();
942
+ return changed;
943
+ }
944
+
848
945
  /** Look up a topic by title, restricted to topics where `userId` participates. */
849
- export function getTopicByNameForUser(title: string, userId: string): TopicDto | null {
946
+ export function getTopicByNameForUser(
947
+ title: string,
948
+ userId: string,
949
+ opts: { surface?: TopicSurface } = {},
950
+ ): TopicDto | null {
850
951
  const trimmed = title.trim();
851
952
  const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
852
953
  const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
@@ -857,11 +958,18 @@ export function getTopicByNameForUser(title: string, userId: string): TopicDto |
857
958
  WHERE LOWER(t.title) = LOWER(?)
858
959
  AND t.id != ?
859
960
  AND t.visibility != 'hidden'
961
+ AND (? IS NULL OR t.surface = ?)
860
962
  AND EXISTS (
861
963
  SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
862
964
  )`,
863
965
  )
864
- .all(requestedTitle, GENERAL_TOPIC_ID, userId) as TopicRow[];
966
+ .all(
967
+ requestedTitle,
968
+ GENERAL_TOPIC_ID,
969
+ opts.surface ?? null,
970
+ opts.surface ?? null,
971
+ userId,
972
+ ) as TopicRow[];
865
973
  const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
866
974
  return matches.length === 1 ? rowToDto(matches[0]!) : null;
867
975
  }
@@ -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.23";
1
+ export const NEGOTIUM_VERSION = "0.2.25";