negotium 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/agent-helpers.js +200 -118
  2. package/dist/agent-helpers.js.map +13 -13
  3. package/dist/hosted-agent.js +2 -2
  4. package/dist/hosted-agent.js.map +2 -2
  5. package/dist/main.js +1124 -511
  6. package/dist/main.js.map +43 -41
  7. package/dist/mcp-factories.js +294 -216
  8. package/dist/mcp-factories.js.map +13 -13
  9. package/dist/registry.js +2 -2
  10. package/dist/registry.js.map +2 -2
  11. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +8 -0
  12. package/dist/runtime/src/index.ts +6 -0
  13. package/dist/runtime/src/mcp/session-comm/default-host.ts +25 -6
  14. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +14 -3
  15. package/dist/runtime/src/mcp/session-comm/server.ts +1 -1
  16. package/dist/runtime/src/mcp/session-comm/topics.ts +52 -16
  17. package/dist/runtime/src/mcp-runtime-host.ts +2 -2
  18. package/dist/runtime/src/node-host.ts +1 -1
  19. package/dist/runtime/src/runtime/turn-event-stream.ts +9 -1
  20. package/dist/runtime/src/runtime/turn-runner.ts +11 -2
  21. package/dist/runtime/src/storage/api-topics.ts +217 -32
  22. package/dist/runtime/src/storage/runtime-turn-requests.ts +26 -2
  23. package/dist/runtime/src/storage/token-stats.ts +27 -2
  24. package/dist/runtime/src/topics/create.ts +27 -1
  25. package/dist/runtime/src/topics/derive.ts +20 -6
  26. package/dist/runtime/src/topics/lifecycle.ts +2 -0
  27. package/dist/runtime/src/topics/personal-general.ts +28 -4
  28. package/dist/runtime/src/types/api.ts +7 -0
  29. package/dist/runtime/src/version.ts +1 -1
  30. package/dist/storage.js +141 -31
  31. package/dist/storage.js.map +4 -4
  32. package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +7 -2
  33. package/dist/types/packages/core/src/runtime/turn-event-stream.d.ts +2 -0
  34. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +3 -0
  35. package/dist/types/packages/core/src/storage/api-topics.d.ts +42 -3
  36. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +6 -0
  37. package/dist/types/packages/core/src/storage/token-stats.d.ts +2 -1
  38. package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
  39. package/dist/types/packages/core/src/topics/personal-general.d.ts +4 -2
  40. package/dist/types/packages/core/src/types/api.d.ts +7 -0
  41. package/dist/types/packages/core/src/version.d.ts +1 -1
  42. package/package.json +1 -1
@@ -34,6 +34,56 @@ export function normalizeTopicSurface(value: unknown): TopicSurface {
34
34
  return value === "telegram" || value === "otium" || value === "terminal" ? value : "terminal";
35
35
  }
36
36
 
37
+ /**
38
+ * The surface instance new rooms are filed under when the caller names none.
39
+ *
40
+ * `surface` alone is not enough once a node may be attached to several Otium
41
+ * workspaces (M-2): two workspaces are two independent namespaces on the same
42
+ * surface. The adapter that owns the attachment installs the active scope at
43
+ * mount time; `terminal` and `telegram` are singletons and stay null.
44
+ *
45
+ * Kept as process state rather than an argument threaded through every creation
46
+ * path because the value is a property of *this process's attachment*, not of
47
+ * any individual call — the same reason `defaultTopicSurface()` exists.
48
+ */
49
+ let activeSurfaceScope: string | null = null;
50
+
51
+ export function normalizeSurfaceScope(value: unknown): string | null {
52
+ if (typeof value !== "string") return null;
53
+ const trimmed = value.trim();
54
+ return trimmed ? trimmed : null;
55
+ }
56
+
57
+ export function defaultSurfaceScope(): string | null {
58
+ return activeSurfaceScope ?? normalizeSurfaceScope(process.env.NEGOTIUM_SURFACE_SCOPE);
59
+ }
60
+
61
+ /** Install (or clear) the scope new rooms inherit. Returns the previous value. */
62
+ export function setDefaultSurfaceScope(scope: string | null): string | null {
63
+ const previous = activeSurfaceScope;
64
+ activeSurfaceScope = normalizeSurfaceScope(scope);
65
+ return previous;
66
+ }
67
+
68
+ /**
69
+ * Whether an Otium room must name a workspace to be created at all (M-3).
70
+ *
71
+ * Off by default, and off for every single-workspace host: an unscoped room is
72
+ * then legacy, reachable, and perfectly usable. The adapter turns it on only
73
+ * while several workspaces are attached, because there an unscoped room is
74
+ * reachable from none of them (M-10) — a room nobody can see is worse than a
75
+ * refusal that says so.
76
+ */
77
+ let surfaceScopeRequired = false;
78
+
79
+ export function setSurfaceScopeRequired(required: boolean): void {
80
+ surfaceScopeRequired = required;
81
+ }
82
+
83
+ export function isSurfaceScopeRequired(): boolean {
84
+ return surfaceScopeRequired;
85
+ }
86
+
37
87
  function tableColumns(table: string): Set<string> {
38
88
  const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
39
89
  return new Set(rows.map((row) => row.name));
@@ -237,6 +287,7 @@ function initializeApiTopicsSchema(): void {
237
287
  is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
238
288
  visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
239
289
  surface TEXT NOT NULL DEFAULT 'terminal' CHECK (surface IN ('terminal','telegram','otium')),
290
+ surface_scope TEXT,
240
291
  browser_profile TEXT NOT NULL DEFAULT 'default',
241
292
  browser_profile_owner TEXT,
242
293
  session_id TEXT,
@@ -305,8 +356,8 @@ function initializeApiTopicsSchema(): void {
305
356
  db.query(
306
357
  `INSERT INTO api_topics_next
307
358
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
308
- created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,session_id)
309
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
359
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,surface_scope,session_id)
360
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
310
361
  ).run(
311
362
  String(row.id),
312
363
  String(row.title),
@@ -325,6 +376,7 @@ function initializeApiTopicsSchema(): void {
325
376
  Number(row.is_subagent ?? 0) !== 0 ? 1 : 0,
326
377
  row.visibility === "hidden" ? "hidden" : "visible",
327
378
  row.surface === undefined ? defaultTopicSurface() : normalizeTopicSurface(row.surface),
379
+ normalizeSurfaceScope(row.surface_scope),
328
380
  typeof row.session_id === "string" ? row.session_id : null,
329
381
  );
330
382
  }
@@ -393,6 +445,9 @@ function initializeApiTopicsSchema(): void {
393
445
  if (!tableColumns("api_topics").has("surface")) {
394
446
  db.exec("ALTER TABLE api_topics ADD COLUMN surface TEXT NOT NULL DEFAULT 'terminal'");
395
447
  }
448
+ if (!tableColumns("api_topics").has("surface_scope")) {
449
+ db.exec("ALTER TABLE api_topics ADD COLUMN surface_scope TEXT");
450
+ }
396
451
  // `access_mode` was replaced by `surface`: a topic is reachable from Otium
397
452
  // because it lives there, not because a flag was flipped (S-4). Dropping the
398
453
  // column removes the second, now-contradictory source of truth.
@@ -433,6 +488,60 @@ function initializeApiTopicsSchema(): void {
433
488
  "CREATE INDEX IF NOT EXISTS idx_api_topics_last_message ON api_topics(last_message_at DESC)",
434
489
  );
435
490
  db.exec("CREATE INDEX IF NOT EXISTS idx_api_topics_surface ON api_topics(surface)");
491
+ db.exec(
492
+ "CREATE INDEX IF NOT EXISTS idx_api_topics_surface_scope ON api_topics(surface, surface_scope)",
493
+ );
494
+ }
495
+
496
+ const SURFACE_SCOPE_STAMP_MIGRATION = "api_topics_surface_scope_stamp_20260809";
497
+
498
+ /**
499
+ * M-9 — file every pre-existing Otium room under the workspace attached now.
500
+ *
501
+ * Deliberately *not* a schema-init step. The scope is only knowable after the
502
+ * first successful Central contact (M-3), which happens long after the store
503
+ * opens; a boot-time migration would see no scope, record itself as applied and
504
+ * leave every room unstamped forever. The Otium runtime calls this instead, once,
505
+ * as soon as it has resolved its workspace.
506
+ *
507
+ * Runs at most once per store: rooms created after this point are stamped at
508
+ * creation, and a second workspace joined later must not swallow the first
509
+ * one's rooms. Unlike the surface migration this can never rename anything —
510
+ * the scope enters the uniqueness key in the same release, so nothing can start
511
+ * colliding because of it.
512
+ */
513
+ export function stampUnscopedOtiumTopics(scope: string): number {
514
+ const normalized = normalizeSurfaceScope(scope);
515
+ if (!normalized) return 0;
516
+ db.exec(`
517
+ CREATE TABLE IF NOT EXISTS api_schema_migrations (
518
+ key TEXT PRIMARY KEY,
519
+ applied_at TEXT NOT NULL
520
+ )
521
+ `);
522
+ const applied = db
523
+ .query("SELECT key FROM api_schema_migrations WHERE key = ?")
524
+ .get(SURFACE_SCOPE_STAMP_MIGRATION);
525
+ if (applied) return 0;
526
+
527
+ let stamped = 0;
528
+ db.transaction(() => {
529
+ stamped = Number(
530
+ db
531
+ .query(
532
+ "UPDATE api_topics SET surface_scope = ? WHERE surface = 'otium' AND surface_scope IS NULL",
533
+ )
534
+ .run(normalized).changes ?? 0,
535
+ );
536
+ db.query("INSERT INTO api_schema_migrations (key, applied_at) VALUES (?, ?)").run(
537
+ SURFACE_SCOPE_STAMP_MIGRATION,
538
+ new Date().toISOString(),
539
+ );
540
+ })();
541
+ if (stamped > 0) {
542
+ logger.info({ scope: normalized, stamped }, "api_topics: surface scope stamped");
543
+ }
544
+ return stamped;
436
545
  }
437
546
 
438
547
  const SURFACE_BACKFILL_MIGRATION = "api_topics_surface_backfill_20260808";
@@ -474,28 +583,41 @@ function backfillTopicSurfaces(): void {
474
583
  logger.info({ surface }, "api_topics: surface backfilled");
475
584
  }
476
585
 
477
- /** Suffix duplicate `(surface, kind, title)` rows so the new uniqueness rule holds. */
586
+ /**
587
+ * Suffix duplicate `(surface, kind, title)` rows so the new uniqueness rule holds.
588
+ *
589
+ * Manager rooms are excluded: they are one per user, so on a multi-user host
590
+ * every member has a personal room with the same title and none of them is a
591
+ * duplicate. Renaming those relabels real rooms belonging to other people.
592
+ */
478
593
  function renameSurfaceTitleCollisions(): void {
479
594
  const rows = db
480
595
  .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",
596
+ "SELECT id, title, kind, surface FROM api_topics WHERE kind != 'manager' ORDER BY created_at ASC, rowid ASC",
482
597
  )
483
598
  .all();
484
- const taken = new Set<string>();
599
+ const keyOf = (surface: string, kind: string, title: string) =>
600
+ [surface, kind, normalizedTitle(title)].join("\u0000");
601
+ // Every original title is reserved up front. Allocating only against the
602
+ // titles seen so far would let an earlier duplicate claim "Foo (2)" while a
603
+ // real room already named "Foo (2)" sits later in the scan — that room would
604
+ // then be pushed to "Foo (2) (2)" for no reason.
605
+ const reserved = new Set(rows.map((row) => keyOf(row.surface, row.kind, row.title)));
606
+ const used = new Set<string>();
485
607
  const update = db.query("UPDATE api_topics SET title = ? WHERE id = ?");
486
608
  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));
609
+ const key = (title: string) => keyOf(row.surface, row.kind, title);
610
+ if (!used.has(key(row.title))) {
611
+ used.add(key(row.title));
490
612
  continue;
491
613
  }
492
614
  let suffix = 2;
493
615
  let candidate = `${row.title} (${suffix})`;
494
- while (taken.has(key(candidate))) {
616
+ while (reserved.has(key(candidate)) || used.has(key(candidate))) {
495
617
  suffix += 1;
496
618
  candidate = `${row.title} (${suffix})`;
497
619
  }
498
- taken.add(key(candidate));
620
+ used.add(key(candidate));
499
621
  update.run(candidate, row.id);
500
622
  logger.warn(
501
623
  { topicId: row.id, surface: row.surface, from: row.title, to: candidate },
@@ -525,6 +647,7 @@ export interface TopicRow {
525
647
  subagent_report_mode: string | null;
526
648
  visibility: string | null;
527
649
  surface: string | null;
650
+ surface_scope: string | null;
528
651
  browser_profile_owner: string | null;
529
652
  session_id: string | null;
530
653
  }
@@ -624,6 +747,7 @@ function rowToDto(
624
747
  : {}),
625
748
  visibility: normalizeTopicVisibility(r.visibility),
626
749
  surface: normalizeTopicSurface(r.surface),
750
+ surfaceScope: normalizeSurfaceScope(r.surface_scope),
627
751
  };
628
752
  }
629
753
 
@@ -720,6 +844,22 @@ function normalizedTitle(title: string): string {
720
844
  return title.trim().toLowerCase();
721
845
  }
722
846
 
847
+ /**
848
+ * The workspace a written row belongs to.
849
+ *
850
+ * Only the `otium` surface has more than one instance, so terminal and telegram
851
+ * are always null — writing this process's Otium scope onto a terminal room
852
+ * would partition the terminal namespace for no reason. An explicit
853
+ * `surfaceScope` in the DTO wins so a hub can file a room it already knows the
854
+ * workspace of; otherwise the room joins whatever workspace this process is
855
+ * attached to, which is null until the scope resolves (M-3).
856
+ */
857
+ function surfaceScopeForWrite(t: TopicDto): string | null {
858
+ if (normalizeTopicSurface(t.surface ?? defaultTopicSurface()) !== "otium") return null;
859
+ if (t.surfaceScope !== undefined) return normalizeSurfaceScope(t.surfaceScope);
860
+ return defaultSurfaceScope();
861
+ }
862
+
723
863
  export function upsertTopic(t: TopicDto): void {
724
864
  const normalized = normalizeTopicState({
725
865
  id: t.id,
@@ -733,8 +873,8 @@ export function upsertTopic(t: TopicDto): void {
733
873
  `INSERT INTO api_topics
734
874
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
735
875
  created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,surface,
736
- subagent_report_mode)
737
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
876
+ surface_scope,subagent_report_mode)
877
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
738
878
  ON CONFLICT(id) DO UPDATE SET
739
879
  title = excluded.title,
740
880
  kind = excluded.kind,
@@ -752,6 +892,10 @@ export function upsertTopic(t: TopicDto): void {
752
892
  is_subagent = excluded.is_subagent,
753
893
  visibility = excluded.visibility,
754
894
  surface = excluded.surface,
895
+ -- A room's workspace is fixed at creation (M-1). COALESCE, not
896
+ -- assignment: an update may fill in a scope that was unknown when the
897
+ -- room was created, but may never move a room to another workspace.
898
+ surface_scope = COALESCE(api_topics.surface_scope, excluded.surface_scope),
755
899
  subagent_report_mode = excluded.subagent_report_mode`,
756
900
  ).run(
757
901
  t.id,
@@ -775,6 +919,7 @@ export function upsertTopic(t: TopicDto): void {
775
919
  // never name one, so defaulting to the literal would file every hub room
776
920
  // on the wrong surface.
777
921
  normalizeTopicSurface(t.surface ?? defaultTopicSurface()),
922
+ surfaceScopeForWrite(t),
778
923
  t.subagentReportMode ?? "auto",
779
924
  );
780
925
  db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
@@ -805,12 +950,23 @@ export function upsertTopic(t: TopicDto): void {
805
950
  * cannot leak a telegram room into the terminal picker; adapters pass their own
806
951
  * surface and get a closed world back.
807
952
  */
808
- export function listTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
953
+ export function listTopics(
954
+ opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
955
+ ): TopicDto[] {
956
+ // `surfaceScope: null` is a real filter ("the unscoped rooms"), so presence —
957
+ // not truthiness — decides whether the scope narrows the query at all.
958
+ const scoped = Object.hasOwn(opts, "surfaceScope");
809
959
  const rows = (
810
960
  opts.surface
811
961
  ? db
812
- .query("SELECT * FROM api_topics WHERE surface = ? ORDER BY last_message_at DESC")
813
- .all(normalizeTopicSurface(opts.surface))
962
+ .query(
963
+ `SELECT * FROM api_topics WHERE surface = ?${scoped ? " AND surface_scope IS ?" : ""} ORDER BY last_message_at DESC`,
964
+ )
965
+ .all(
966
+ ...(scoped
967
+ ? [normalizeTopicSurface(opts.surface), normalizeSurfaceScope(opts.surfaceScope)]
968
+ : [normalizeTopicSurface(opts.surface)]),
969
+ )
814
970
  : db.query("SELECT * FROM api_topics ORDER BY last_message_at DESC").all()
815
971
  ) as TopicRow[];
816
972
  const participants = getAllTopicParticipants();
@@ -828,19 +984,35 @@ export function getTopic(id: string): TopicDto | null {
828
984
  }
829
985
 
830
986
  /** Return the private manager room owned by a user, excluding the retired shared General row. */
831
- export function getManagerTopicForUser(userId: string): TopicDto | null {
832
- const row = db
833
- .query<TopicRow, [string, string]>(
834
- `SELECT t.* FROM api_topics t
987
+ /**
988
+ * The user's own manager room, optionally on one surface.
989
+ *
990
+ * A manager room is one per user **per surface**: Terminal and Telegram each
991
+ * give the same person their own personal room, and returning one surface's
992
+ * room to the other adapter lets that adapter bind to — and reclassify — a room
993
+ * belonging to a different surface. Callers that represent an adapter pass
994
+ * theirs; unscoped lookups stay for maintenance paths that work by user alone.
995
+ */
996
+ export function getManagerTopicForUser(
997
+ userId: string,
998
+ surface?: TopicSurface,
999
+ opts: { surfaceScope?: string | null } = {},
1000
+ ): TopicDto | null {
1001
+ const scoped = Object.hasOwn(opts, "surfaceScope");
1002
+ const sql = `SELECT t.* FROM api_topics t
835
1003
  JOIN topic_members m ON m.topic_id = t.id
836
1004
  WHERE t.kind = 'manager'
837
1005
  AND t.id != ?
838
1006
  AND m.user_id = ?
839
1007
  AND m.role = 'owner'
1008
+ ${surface ? "AND t.surface = ?" : ""}
1009
+ ${scoped ? "AND t.surface_scope IS ?" : ""}
840
1010
  ORDER BY t.created_at ASC
841
- LIMIT 1`,
842
- )
843
- .get(GENERAL_TOPIC_ID, userId);
1011
+ LIMIT 1`;
1012
+ const params: Array<string | null> = [GENERAL_TOPIC_ID, userId];
1013
+ if (surface) params.push(surface);
1014
+ if (scoped) params.push(normalizeSurfaceScope(opts.surfaceScope));
1015
+ const row = db.query<TopicRow, Array<string | null>>(sql).get(...params);
844
1016
  return row ? rowToDto(row) : null;
845
1017
  }
846
1018
 
@@ -895,16 +1067,23 @@ export function getTopicByNameAndKind(title: string, kind: TopicKind): TopicDto
895
1067
  }
896
1068
 
897
1069
  /**
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.
1070
+ * Titles are unique **per surface instance**, not per node: `otium` may exist
1071
+ * once on the terminal, once on telegram, and once in *each* attached Otium
1072
+ * workspace (M-1). Two workspaces are two namespaces that never see each other,
1073
+ * so a name taken in one says nothing about the other.
900
1074
  */
901
1075
  export function findTopicTitleConflict(
902
1076
  title: string,
903
1077
  kind: TopicKind,
904
- opts: { excludeTopicId?: string; surface?: TopicSurface } = {},
1078
+ opts: { excludeTopicId?: string; surface?: TopicSurface; surfaceScope?: string | null } = {},
905
1079
  ): TopicDto | null {
906
1080
  const wanted = normalizedTitle(title);
907
1081
  const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
1082
+ const surfaceScope = Object.hasOwn(opts, "surfaceScope")
1083
+ ? normalizeSurfaceScope(opts.surfaceScope)
1084
+ : surface === "otium"
1085
+ ? defaultSurfaceScope()
1086
+ : null;
908
1087
  const generalTitleRequested = wanted === normalizedTitle(GENERAL_TOPIC_ID);
909
1088
  if (generalTitleRequested && opts.excludeTopicId !== GENERAL_TOPIC_ID) {
910
1089
  const general = db.query("SELECT * FROM api_topics WHERE id = ?").get(GENERAL_TOPIC_ID) as
@@ -913,12 +1092,15 @@ export function findTopicTitleConflict(
913
1092
  if (general) return rowToDto(general);
914
1093
  }
915
1094
 
916
- const params: string[] = [wanted, surface];
917
- let sql = "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ?";
918
- if (kind !== "manager") {
919
- sql += " AND (kind = ? OR id = ?)";
920
- params.push(kind, GENERAL_TOPIC_ID);
921
- }
1095
+ // Manager rooms are one-per-user and system-created, so several of them
1096
+ // legitimately share a title (every member's personal "General"). Comparing
1097
+ // them against each other is not a conflict, it is the design — on a
1098
+ // multi-user host it renamed real rooms out from under their owners.
1099
+ if (kind === "manager") return null;
1100
+
1101
+ const params: Array<string | null> = [wanted, surface, surfaceScope, kind, GENERAL_TOPIC_ID];
1102
+ let sql =
1103
+ "SELECT * FROM api_topics WHERE LOWER(TRIM(title)) = ? AND surface = ? AND surface_scope IS ? AND (kind = ? OR id = ?)";
922
1104
  if (opts.excludeTopicId) {
923
1105
  sql += " AND id != ?";
924
1106
  params.push(opts.excludeTopicId);
@@ -950,12 +1132,13 @@ export function setTopicSurfaces(topicIds: readonly string[], surface: TopicSurf
950
1132
  export function getTopicByNameForUser(
951
1133
  title: string,
952
1134
  userId: string,
953
- opts: { surface?: TopicSurface } = {},
1135
+ opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
954
1136
  ): TopicDto | null {
955
1137
  const trimmed = title.trim();
956
1138
  const qualified = /^(agent|channel|manager):(.+)$/i.exec(trimmed);
957
1139
  const requestedKind = qualified ? normalizeTopicKind(qualified[1]?.toLowerCase()) : null;
958
1140
  const requestedTitle = qualified ? qualified[2]!.trim() : trimmed;
1141
+ const scoped = Object.hasOwn(opts, "surfaceScope");
959
1142
  const rows = db
960
1143
  .query(
961
1144
  `SELECT t.* FROM api_topics t
@@ -963,6 +1146,7 @@ export function getTopicByNameForUser(
963
1146
  AND t.id != ?
964
1147
  AND t.visibility != 'hidden'
965
1148
  AND (? IS NULL OR t.surface = ?)
1149
+ ${scoped ? "AND t.surface_scope IS ?" : ""}
966
1150
  AND EXISTS (
967
1151
  SELECT 1 FROM topic_members m WHERE m.topic_id = t.id AND m.user_id = ?
968
1152
  )`,
@@ -972,6 +1156,7 @@ export function getTopicByNameForUser(
972
1156
  GENERAL_TOPIC_ID,
973
1157
  opts.surface ?? null,
974
1158
  opts.surface ?? null,
1159
+ ...(scoped ? [normalizeSurfaceScope(opts.surfaceScope)] : []),
975
1160
  userId,
976
1161
  ) as TopicRow[];
977
1162
  const matches = requestedKind ? rows.filter((row) => row.kind === requestedKind) : rows;
@@ -42,6 +42,12 @@ export interface RuntimeUserTurnExecution {
42
42
  providerSessionId?: string;
43
43
  /** Request ids whose ordered messages were folded into this replacement. */
44
44
  supersededRequestIds?: string[];
45
+ /**
46
+ * Slack-style thread this turn belongs to, when it was asked in one. Carried
47
+ * on the request rather than the message because it decides both where the
48
+ * answer goes and which pending requests may merge with it (S-13).
49
+ */
50
+ threadRootId?: string;
45
51
  }
46
52
 
47
53
  export interface RuntimeUserTurnRequest {
@@ -312,7 +318,16 @@ export function mergeRuntimeUserTurnRequest(input: {
312
318
  "SELECT * FROM runtime_user_turn_requests WHERE topic_id = ? ORDER BY created_at ASC, rowid ASC",
313
319
  )
314
320
  .all(input.topicId);
315
- const previous = rows.map(rowToRequest);
321
+ // Merging exists to fold consecutive utterances of one conversation into
322
+ // a single turn. A different thread is by definition a different
323
+ // conversation, and a merged batch spanning two of them has no correct
324
+ // place to answer — so requests are only ever folded within one thread,
325
+ // and a pending request from another thread is left standing to run on
326
+ // its own (S-13).
327
+ const thread = input.execution.threadRootId;
328
+ const previous = rows
329
+ .map(rowToRequest)
330
+ .filter((request) => request.execution?.threadRootId === thread);
316
331
  const omittedRequestIds = new Set([
317
332
  ...(input.omitRequestIds ?? []),
318
333
  ...previous
@@ -371,7 +386,16 @@ export function mergeRuntimeUserTurnRequest(input: {
371
386
  }
372
387
  const attachments = flattenUserTurnAttachments(userMessages);
373
388
 
374
- db.query("DELETE FROM runtime_user_turn_requests WHERE topic_id = ?").run(input.topicId);
389
+ // Delete only what this replacement actually absorbed. Clearing the whole
390
+ // topic would drop another thread's pending question on the floor — it
391
+ // was never folded in, so nothing would ever answer it (S-13).
392
+ const absorbed = previous.map((request) => request.requestId);
393
+ if (absorbed.length > 0) {
394
+ db.query(
395
+ `DELETE FROM runtime_user_turn_requests
396
+ WHERE topic_id = ? AND request_id IN (${absorbed.map(() => "?").join(",")})`,
397
+ ).run(input.topicId, ...absorbed);
398
+ }
375
399
  db.query(
376
400
  `INSERT INTO runtime_user_turn_requests
377
401
  (request_id, topic_id, user_id, prompt, user_messages_json, attachments_json,
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { appendJsonlEntry, readJsonlLines } from "#platform/jsonl";
5
5
  import { logger } from "#platform/logger";
6
+ import { getTopicSessionId } from "#storage/api-topics";
6
7
  import { resolveStorageLogDir } from "#storage/storage-host";
7
8
  import type { AgentKind, TokenUsage } from "#types";
8
9
 
@@ -207,6 +208,24 @@ export function recordUsage(
207
208
  }
208
209
  }
209
210
 
211
+ export function deleteTopicStats(userId: number | string, topicId: string): void {
212
+ const path = queriesPath(userId);
213
+ try {
214
+ const kept = readJsonlLines(path).filter((line) => {
215
+ try {
216
+ const record = JSON.parse(line) as { topicId?: unknown };
217
+ return record.topicId !== topicId;
218
+ } catch {
219
+ return true;
220
+ }
221
+ });
222
+ writeFileSync(path, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf-8");
223
+ } catch (e) {
224
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return;
225
+ logger.warn({ err: e, userId, topicId }, "token-stats: Failed to delete topic stats");
226
+ }
227
+ }
228
+
210
229
  export function getStats(
211
230
  userId: number | string,
212
231
  from?: string,
@@ -292,7 +311,11 @@ export function getStats(
292
311
  }
293
312
 
294
313
  /** Exact all-time usage for one topic, independent of transcript pagination. */
295
- export function getTopicStats(userId: number | string, topicId: string): TopicUsageSummary {
314
+ export function getTopicStats(
315
+ userId: number | string,
316
+ topicId: string,
317
+ activeProviderSessionId = getTopicSessionId(topicId) ?? undefined,
318
+ ): TopicUsageSummary {
296
319
  const total = emptyBucket();
297
320
  let currentSession: CurrentSessionUsage | undefined;
298
321
 
@@ -310,6 +333,8 @@ export function getTopicStats(userId: number | string, topicId: string): TopicUs
310
333
  raw.contextTokens !== undefined &&
311
334
  raw.contextWindow !== undefined &&
312
335
  raw.contextWindow > 0 &&
336
+ activeProviderSessionId !== undefined &&
337
+ raw.providerSessionId === activeProviderSessionId &&
313
338
  (!currentSession || raw.timestamp > currentSession.timestamp)
314
339
  ) {
315
340
  currentSession = {
@@ -14,8 +14,11 @@ 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
+ defaultSurfaceScope,
17
18
  defaultTopicSurface,
18
19
  findTopicTitleConflict,
20
+ isSurfaceScopeRequired,
21
+ normalizeSurfaceScope,
19
22
  normalizeTopicKind,
20
23
  normalizeTopicState,
21
24
  normalizeTopicSurface,
@@ -50,6 +53,11 @@ export interface RegisterTopicOptions {
50
53
  * adapter must pass their own surface; unset falls back to the host default.
51
54
  */
52
55
  surface?: TopicSurface;
56
+ /**
57
+ * Which instance of that surface owns the room. Only meaningful for `otium`;
58
+ * unset means "the workspace this process is attached to".
59
+ */
60
+ surfaceScope?: string | null;
53
61
  }
54
62
 
55
63
  /**
@@ -68,7 +76,24 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
68
76
  throw new TopicValidationError("Manager rooms are system-managed");
69
77
  }
70
78
  const surface = normalizeTopicSurface(opts.surface ?? defaultTopicSurface());
71
- const conflict = findTopicTitleConflict(title, requestedKind, { surface });
79
+ // Which instance of that surface — for Otium, which workspace (M-1). Names
80
+ // collide only inside one workspace, so the conflict check must be scoped the
81
+ // same way the row will be written.
82
+ const surfaceScope =
83
+ opts.surfaceScope !== undefined
84
+ ? normalizeSurfaceScope(opts.surfaceScope)
85
+ : surface === "otium"
86
+ ? defaultSurfaceScope()
87
+ : null;
88
+ // M-3: a room that names no workspace while several are attached would be
89
+ // filed under none and therefore visible to none. Refuse it here, where the
90
+ // caller can still be told, rather than creating a room nobody can reach.
91
+ if (surface === "otium" && !surfaceScope && isSurfaceScopeRequired()) {
92
+ throw new TopicValidationError(
93
+ "this node serves several Otium workspaces; a room must name one to be created",
94
+ );
95
+ }
96
+ const conflict = findTopicTitleConflict(title, requestedKind, { surface, surfaceScope });
72
97
  if (conflict) {
73
98
  throw new TopicValidationError(`A topic named "${title}" already exists on ${surface}`);
74
99
  }
@@ -113,6 +138,7 @@ export function registerTopic(opts: RegisterTopicOptions): TopicDto {
113
138
  aiMode,
114
139
  participants: [{ userId: opts.userId, role: "owner" }],
115
140
  surface,
141
+ surfaceScope,
116
142
  createdAt: now,
117
143
  lastMessageAt: now,
118
144
  };
@@ -56,7 +56,9 @@ import {
56
56
  import type { AgentKind } from "#types";
57
57
  import type { TopicDto, TopicSurface } from "#types/api";
58
58
 
59
- export function getTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
59
+ export function getTopics(
60
+ opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
61
+ ): TopicDto[] {
60
62
  return listTopics(opts).filter((topic) => !isLegacySharedGeneral(topic.id));
61
63
  }
62
64
 
@@ -66,7 +68,9 @@ export function getTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
66
68
  * Callers that represent one product surface pass it, so a telegram room never
67
69
  * appears in the terminal picker and vice versa (S-6).
68
70
  */
69
- export function getVisibleTopics(opts: { surface?: TopicSurface } = {}): TopicDto[] {
71
+ export function getVisibleTopics(
72
+ opts: { surface?: TopicSurface; surfaceScope?: string | null } = {},
73
+ ): TopicDto[] {
70
74
  return getTopics(opts)
71
75
  .filter(isTopicVisible)
72
76
  .map((topic) => {
@@ -107,9 +111,10 @@ function nextDerivedTopicTitle(
107
111
  kind: TopicDto["kind"],
108
112
  suffix: "fork" | "spawn" | "agent",
109
113
  surface?: TopicSurface,
114
+ surfaceScope?: string | null,
110
115
  ): string {
111
116
  const visibleTitles = new Set(
112
- listTopics(surface ? { surface } : {})
117
+ listTopics(surface ? { surface, surfaceScope: surfaceScope ?? null } : {})
113
118
  .filter((topic) => topic.kind === kind)
114
119
  .map((topic) => topic.title.toLowerCase()),
115
120
  );
@@ -272,8 +277,13 @@ async function createDerivedTopicImpl(
272
277
  : [{ userId, role: "owner" as const }];
273
278
  const kind = topic.kind ?? inferTopicKind(topic);
274
279
  const surface = topic.surface ?? defaultTopicSurface();
275
- const title = opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface);
276
- const conflict = findTopicTitleConflict(title, kind, { surface });
280
+ // A derived room joins its parent's workspace, not this process's current
281
+ // one: a subagent spawned from a workspace-A room belongs to workspace A even
282
+ // if the node has since attached to another (M-1).
283
+ const surfaceScope = topic.surfaceScope ?? null;
284
+ const title =
285
+ opts?.name?.trim() || nextDerivedTopicTitle(topic.title, kind, suffix, surface, surfaceScope);
286
+ const conflict = findTopicTitleConflict(title, kind, { surface, surfaceScope });
277
287
  if (conflict) {
278
288
  logger.info(
279
289
  { sourceTopicId, title, kind, conflictTopicId: conflict.id },
@@ -301,6 +311,7 @@ async function createDerivedTopicImpl(
301
311
  visibility: topic.visibility,
302
312
  // A derived room lives on the same surface as the room it came from.
303
313
  surface,
314
+ surfaceScope,
304
315
  };
305
316
 
306
317
  let sessionId: string | undefined;
@@ -452,7 +463,10 @@ async function createDerivedTopicImpl(
452
463
  ) {
453
464
  throw new TopicDeriveBusyError("Source topic changed while deriving; try again");
454
465
  }
455
- const transactionalConflict = findTopicTitleConflict(title, kind, { surface });
466
+ const transactionalConflict = findTopicTitleConflict(title, kind, {
467
+ surface,
468
+ surfaceScope,
469
+ });
456
470
  if (transactionalConflict) throw new TopicTitleConflictError(title);
457
471
  upsertTopic(derived);
458
472
  if (subagent) {
@@ -47,6 +47,7 @@ import {
47
47
  } from "#storage/runtime-turn-requests";
48
48
  import { deleteSelfSchedulesForTopic } from "#storage/self-schedules";
49
49
  import { deletePendingAsksForTopic } from "#storage/session-asks";
50
+ import { deleteTopicStats } from "#storage/token-stats";
50
51
  import { archiveConversationEvents, archiveTopicMessages } from "#storage/topic-archive";
51
52
  import { deleteTopicArchiveState } from "#storage/topic-archive-state";
52
53
  import type { TopicDto } from "#types/api";
@@ -111,6 +112,7 @@ async function cleanupParticipantResources(
111
112
  cleanupSessionInboxFiles(participantUserId, topic.id, topic.title);
112
113
  clearQueryState(participantUserId, topic.id, topic.title);
113
114
  clearQueryUsageAlert(participantUserId, topic.id);
115
+ deleteTopicStats(participantUserId, topic.id);
114
116
  deletePendingAsksForTopic({ userId: participantUserId, topicName: topic.title });
115
117
  }
116
118
  return true;