negotium 0.1.13 → 0.1.15

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.
package/dist/main.js CHANGED
@@ -3324,7 +3324,7 @@ var init_claude_provider = __esm(async () => {
3324
3324
  });
3325
3325
 
3326
3326
  // ../../packages/core/src/version.ts
3327
- var NEGOTIUM_VERSION = "0.1.13";
3327
+ var NEGOTIUM_VERSION = "0.1.15";
3328
3328
 
3329
3329
  // ../../packages/core/src/agents/codex-native-multi-agent.ts
3330
3330
  import { spawn as spawn2 } from "child_process";
@@ -4520,6 +4520,8 @@ function initializeApiMessagesSchema() {
4520
4520
  parent_id TEXT,
4521
4521
  author_id TEXT NOT NULL,
4522
4522
  source_adapter TEXT,
4523
+ source_node TEXT,
4524
+ source_message_id TEXT,
4523
4525
  text TEXT NOT NULL,
4524
4526
  query_id TEXT,
4525
4527
  agent_type TEXT,
@@ -4539,6 +4541,12 @@ function initializeApiMessagesSchema() {
4539
4541
  try {
4540
4542
  db.exec("ALTER TABLE api_messages ADD COLUMN source_adapter TEXT");
4541
4543
  } catch {}
4544
+ try {
4545
+ db.exec("ALTER TABLE api_messages ADD COLUMN source_node TEXT");
4546
+ } catch {}
4547
+ try {
4548
+ db.exec("ALTER TABLE api_messages ADD COLUMN source_message_id TEXT");
4549
+ } catch {}
4542
4550
  try {
4543
4551
  db.exec("ALTER TABLE api_messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0");
4544
4552
  } catch {}
@@ -4584,6 +4592,8 @@ function rowToDto(r) {
4584
4592
  parentId: r.parent_id ?? undefined,
4585
4593
  authorId: r.author_id,
4586
4594
  sourceAdapter: r.source_adapter ?? undefined,
4595
+ sourceNode: r.source_node ?? undefined,
4596
+ sourceMessageId: r.source_message_id ?? undefined,
4587
4597
  text: r.text,
4588
4598
  queryId: r.query_id ?? undefined,
4589
4599
  agentType: r.agent_type ?? undefined,
@@ -4617,9 +4627,9 @@ function appendApiMessage(msg, options = {}) {
4617
4627
  let inserted = false;
4618
4628
  db.transaction(() => {
4619
4629
  const result = db.query(`INSERT INTO api_messages
4620
- (id, topic_id, parent_id, author_id, source_adapter, text, query_id, agent_type, model, attachments, usage, deleted, edited_at, reactions, kind, ask_user_question, subagent_card, mentions, thread_root_id, created_at)
4621
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4622
- ON CONFLICT(id) DO NOTHING`).run(msg.id, msg.topicId, msg.parentId ?? null, msg.authorId, msg.sourceAdapter ?? null, msg.text, msg.queryId ?? null, msg.agentType ?? null, msg.model ?? null, msg.attachments ? JSON.stringify(msg.attachments) : null, msg.usage ? JSON.stringify(msg.usage) : null, msg.deleted ? 1 : 0, msg.editedAt ?? null, msg.reactions?.length ? JSON.stringify(msg.reactions) : null, msg.kind ?? null, msg.askUserQuestion ? JSON.stringify(msg.askUserQuestion) : null, msg.subagentCard ? JSON.stringify(msg.subagentCard) : null, msg.mentions?.length ? JSON.stringify(msg.mentions) : null, msg.threadRootId ?? null, msg.createdAt);
4630
+ (id, topic_id, parent_id, author_id, source_adapter, source_node, source_message_id, text, query_id, agent_type, model, attachments, usage, deleted, edited_at, reactions, kind, ask_user_question, subagent_card, mentions, thread_root_id, created_at)
4631
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4632
+ ON CONFLICT(id) DO NOTHING`).run(msg.id, msg.topicId, msg.parentId ?? null, msg.authorId, msg.sourceAdapter ?? null, msg.sourceNode ?? null, msg.sourceMessageId ?? null, msg.text, msg.queryId ?? null, msg.agentType ?? null, msg.model ?? null, msg.attachments ? JSON.stringify(msg.attachments) : null, msg.usage ? JSON.stringify(msg.usage) : null, msg.deleted ? 1 : 0, msg.editedAt ?? null, msg.reactions?.length ? JSON.stringify(msg.reactions) : null, msg.kind ?? null, msg.askUserQuestion ? JSON.stringify(msg.askUserQuestion) : null, msg.subagentCard ? JSON.stringify(msg.subagentCard) : null, msg.mentions?.length ? JSON.stringify(msg.mentions) : null, msg.threadRootId ?? null, msg.createdAt);
4623
4633
  inserted = Number(result.changes ?? 0) > 0;
4624
4634
  if (inserted && updateTopicLastMessageAt && !msg.deleted) {
4625
4635
  db.query(`UPDATE api_topics
@@ -7420,6 +7430,7 @@ function updateTopic(topicId, patch) {
7420
7430
  return false;
7421
7431
  Object.assign(topic, patch);
7422
7432
  upsertTopic(topic);
7433
+ WsHub.get().broadcastTopicUpdated(topicId);
7423
7434
  return true;
7424
7435
  }
7425
7436
  function isParticipant(topic, userId) {
@@ -11356,6 +11367,7 @@ async function streamAgentEvents(topicId, topicTitle, queryId, events, control,
11356
11367
  queryId,
11357
11368
  agentType,
11358
11369
  model,
11370
+ sourceNode: execution?.sourceNode,
11359
11371
  usage,
11360
11372
  createdAt: new Date().toISOString()
11361
11373
  };
@@ -11832,6 +11844,7 @@ function redispatchInject(inject) {
11832
11844
  bridgeSessionFromHistory: inject.bridgeSessionFromHistory,
11833
11845
  onSettled: inject.onSettled,
11834
11846
  peerBridge: inject.peerBridge,
11847
+ sourceNode: inject.sourceNode,
11835
11848
  askReplySources: inject.askReplySources,
11836
11849
  _runtimeEpoch: inject.runtimeEpoch,
11837
11850
  _sessionRetried: inject._sessionRetried,
@@ -12158,6 +12171,7 @@ function startAiTurn(params) {
12158
12171
  let sessionId = sessionResolution.sessionId;
12159
12172
  const deferredSessionId = params.sessionId === undefined && !sessionResolution.isolated ? undefined : sessionId;
12160
12173
  const origin = params.origin ?? "user";
12174
+ const sourceNode = params.sourceNode;
12161
12175
  const topicId = topic.id;
12162
12176
  const requestId = params.requestId;
12163
12177
  const depth = params.depth;
@@ -12207,6 +12221,7 @@ function startAiTurn(params) {
12207
12221
  userId,
12208
12222
  prompt,
12209
12223
  origin,
12224
+ sourceNode,
12210
12225
  requestId,
12211
12226
  depth,
12212
12227
  silent,
@@ -12598,7 +12613,7 @@ ${playwrightNote}`;
12598
12613
  WsHub.get().broadcastTyping(topicId, "ai");
12599
12614
  WsHub.get().broadcastAiActive(topicId, queryId);
12600
12615
  }
12601
- streamAgentEvents(topicId, topic.title, queryId, events, control, agentKind, resolvedModel, resolvedEffort, userId, !sessionRetried, onSessionId, { silent, peerBridge }).then(async (outcome) => {
12616
+ streamAgentEvents(topicId, topic.title, queryId, events, control, agentKind, resolvedModel, resolvedEffort, userId, !sessionRetried, onSessionId, { silent, peerBridge, sourceNode }).then(async (outcome) => {
12602
12617
  if (outcome.kind === "session-expired") {
12603
12618
  if (!silent)
12604
12619
  WsHub.get().broadcastAborted(topicId, queryId, "stopped");
@@ -12743,6 +12758,8 @@ function triggerTopicAiTurn(topicId, userId, prompt, agentType, opts) {
12743
12758
  topicId,
12744
12759
  authorId: opts?.injectAuthorId ?? userId,
12745
12760
  sourceAdapter: opts?.injectSourceAdapter,
12761
+ sourceNode: opts?.injectSourceNode,
12762
+ sourceMessageId: opts?.injectSourceMessageId,
12746
12763
  authorName: execution.agent,
12747
12764
  text: prompt,
12748
12765
  agentType: execution.agent,
@@ -12782,6 +12799,7 @@ function triggerTopicAiTurn(topicId, userId, prompt, agentType, opts) {
12782
12799
  bridgeSessionFromHistory: opts?.bridgeSessionFromHistory,
12783
12800
  onSettled: opts?.onSettled,
12784
12801
  peerBridge: opts?.peerBridge,
12802
+ sourceNode: opts?.injectSourceNode,
12785
12803
  askReplySources: opts?.askReplySources,
12786
12804
  from: opts?.from
12787
12805
  });
@@ -13769,6 +13787,28 @@ var init_submit_user_message = __esm(async () => {
13769
13787
  await init_api_messages();
13770
13788
  });
13771
13789
 
13790
+ // ../../packages/core/src/application/switch-topic-access-mode.ts
13791
+ function switchTopicAccessMode(params) {
13792
+ const topic = getTopic(params.topicId);
13793
+ if (!topic)
13794
+ return { ok: false, error: "Topic not found" };
13795
+ const owner = topic.participants.some((participant) => participant.userId === params.userId && participant.role === "owner");
13796
+ if (!owner)
13797
+ return { ok: false, error: "Only topic owners can change privacy" };
13798
+ topic.accessMode = params.accessMode;
13799
+ upsertTopic(topic);
13800
+ WsHub.get().broadcastTopicUpdated(topic.id);
13801
+ return {
13802
+ ok: true,
13803
+ accessMode: params.accessMode,
13804
+ text: params.accessMode === "shared" ? `"${topic.title}" is public to the connected Otium Hub.` : `"${topic.title}" is private to this worker.`
13805
+ };
13806
+ }
13807
+ var init_switch_topic_access_mode = __esm(async () => {
13808
+ await init_bus();
13809
+ await init_api_topics();
13810
+ });
13811
+
13772
13812
  // ../../packages/core/src/application/switch-topic-effort.ts
13773
13813
  function switchTopicEffort(params) {
13774
13814
  const topic = getTopic(params.topicId);
@@ -14799,6 +14839,10 @@ function getRuntimeProcessLease(role, now = Date.now(), staleMs = PROCESS_LEASE_
14799
14839
  const lease = rowToLease2(row);
14800
14840
  return now - lease.heartbeatAt <= staleMs ? lease : null;
14801
14841
  }
14842
+ function listRuntimeProcessLeases(rolePrefix = "", now = Date.now(), staleMs = PROCESS_LEASE_STALE_MS) {
14843
+ const rows = rolePrefix ? db.query("SELECT * FROM runtime_process_leases WHERE role LIKE ? ORDER BY role").all(`${rolePrefix}%`) : db.query("SELECT * FROM runtime_process_leases ORDER BY role").all();
14844
+ return rows.map(rowToLease2).filter((lease) => now - lease.heartbeatAt <= staleMs);
14845
+ }
14802
14846
  function heartbeatRuntimeProcessLease(role, ownerId, now = Date.now()) {
14803
14847
  const result = db.query(`UPDATE runtime_process_leases
14804
14848
  SET heartbeat_at = ?
@@ -15961,6 +16005,7 @@ __export(exports_src, {
15961
16005
  textResult: () => textResult,
15962
16006
  switchTopicModel: () => switchTopicModel,
15963
16007
  switchTopicEffort: () => switchTopicEffort,
16008
+ switchTopicAccessMode: () => switchTopicAccessMode,
15964
16009
  sweepStaleSubagentCards: () => sweepStaleSubagentCards,
15965
16010
  submitUserMessage: () => submitUserMessage,
15966
16011
  stripFileTags: () => stripFileTags,
@@ -16016,6 +16061,7 @@ __export(exports_src, {
16016
16061
  listVaultEntries: () => listVaultEntries,
16017
16062
  listTopics: () => listTopics,
16018
16063
  listRuntimeTurnLeases: () => listRuntimeTurnLeases,
16064
+ listRuntimeProcessLeases: () => listRuntimeProcessLeases,
16019
16065
  listRuntimeEventsAfter: () => listRuntimeEventsAfter,
16020
16066
  listRunningTopicQueries: () => listRunningTopicQueries,
16021
16067
  listRunningTopicIds: () => listRunningTopicIds,
@@ -16152,6 +16198,7 @@ var init_src = __esm(async () => {
16152
16198
  await init_topic_cleanup();
16153
16199
  await init_execute_external_user_turn();
16154
16200
  await init_submit_user_message();
16201
+ await init_switch_topic_access_mode();
16155
16202
  await init_switch_topic_effort();
16156
16203
  await init_switch_topic_model();
16157
16204
  await init_topic_service();
@@ -17852,14 +17899,17 @@ function createNodeControlHandler(options) {
17852
17899
  const topic = topicForUser(topicId, userId);
17853
17900
  if (!topic)
17854
17901
  return jsonError(404, "Topic not found");
17855
- const { message } = submitUserMessage({
17902
+ const sourceAdapter = body.sourceAdapter === "telegram" || body.sourceAdapter === "otium" ? body.sourceAdapter : "terminal";
17903
+ const { message, queryId } = submitUserMessage({
17856
17904
  topic,
17857
17905
  userId,
17858
17906
  text: text2,
17859
- sourceAdapter: "terminal",
17907
+ sourceAdapter,
17908
+ visualTools: body.visualTools === true,
17909
+ fileDeliveryTools: body.fileDeliveryTools === true,
17860
17910
  startTurn: options.startTurn
17861
17911
  });
17862
- return Response.json({ ok: true, message }, { status: 201 });
17912
+ return Response.json({ ok: true, message, queryId }, { status: 201 });
17863
17913
  }
17864
17914
  const recentMatch = path.match(/^\/topics\/([^/]+)\/recent-events$/);
17865
17915
  if (recentMatch && req.method === "GET") {
@@ -17883,6 +17933,24 @@ function createNodeControlHandler(options) {
17883
17933
  return jsonError(400, result.error);
17884
17934
  return Response.json({ ok: true, model: result.model, result: result.text });
17885
17935
  }
17936
+ const accessModeMatch = path.match(/^\/topics\/([^/]+)\/access-mode$/);
17937
+ if (accessModeMatch && req.method === "POST") {
17938
+ const topicId = decodeURIComponent(accessModeMatch[1]);
17939
+ const body = await bodyRecord(req);
17940
+ const userId = requiredText(body.userId, "userId");
17941
+ const accessMode = requiredText(body.accessMode, "accessMode");
17942
+ if (accessMode !== "private" && accessMode !== "shared") {
17943
+ return jsonError(400, `Unknown access mode: ${accessMode}`);
17944
+ }
17945
+ const result = switchTopicAccessMode({ topicId, userId, accessMode });
17946
+ if (!result.ok)
17947
+ return jsonError(400, result.error);
17948
+ return Response.json({
17949
+ ok: true,
17950
+ accessMode: result.accessMode,
17951
+ result: result.text
17952
+ });
17953
+ }
17886
17954
  const effortMatch = path.match(/^\/topics\/([^/]+)\/effort$/);
17887
17955
  if (effortMatch && req.method === "POST") {
17888
17956
  const topicId = decodeURIComponent(effortMatch[1]);
@@ -20013,6 +20081,7 @@ async function startDefaultNode(opts = {}) {
20013
20081
  async function runNodeDaemon(opts = {}) {
20014
20082
  const node = await startDefaultNode({
20015
20083
  port: opts.port ?? 0,
20084
+ ...opts.maxRequestBodySize ? { maxRequestBodySize: opts.maxRequestBodySize } : {},
20016
20085
  advertise: true,
20017
20086
  singleton: true
20018
20087
  });
@@ -20199,6 +20268,16 @@ class EmbeddedNegotiumClient {
20199
20268
  throw new Error(result.error);
20200
20269
  return result.text;
20201
20270
  }
20271
+ setAccessMode(topic, accessMode) {
20272
+ const result = switchTopicAccessMode({
20273
+ topicId: topic.id,
20274
+ userId: this.#userId,
20275
+ accessMode
20276
+ });
20277
+ if (!result.ok)
20278
+ throw new Error(result.error);
20279
+ return result.text;
20280
+ }
20202
20281
  async deleteTopic(topic) {
20203
20282
  await topicService.delete({ topicId: topic.id, userId: this.#userId });
20204
20283
  }
@@ -20377,6 +20456,13 @@ class RemoteNegotiumClient {
20377
20456
  });
20378
20457
  return String(result.result ?? `Effort set to '${effort}'.`);
20379
20458
  }
20459
+ async setAccessMode(topic, accessMode) {
20460
+ const result = await this.#request(`/topics/${encodeURIComponent(topic.id)}/access-mode`, {
20461
+ method: "POST",
20462
+ body: JSON.stringify({ userId: this.#userId, accessMode })
20463
+ });
20464
+ return String(result.result ?? `Access mode set to '${accessMode}'.`);
20465
+ }
20380
20466
  async deleteTopic(topic) {
20381
20467
  await this.#request(`/topics/${encodeURIComponent(topic.id)}?user=${encodeURIComponent(this.#userId)}`, { method: "DELETE" });
20382
20468
  }
@@ -20629,7 +20715,7 @@ var MAX_CLIPBOARD_BYTES = 1e5;
20629
20715
 
20630
20716
  // ../../adapters/terminal/src/clock.ts
20631
20717
  function terminalNowMs() {
20632
- return performance.timeOrigin + performance.now();
20718
+ return Date.now();
20633
20719
  }
20634
20720
 
20635
20721
  // ../../adapters/terminal/src/commands.ts
@@ -20659,6 +20745,8 @@ var init_commands = __esm(() => {
20659
20745
  { name: "model", usage: "/model", description: "choose the model" },
20660
20746
  { name: "effort", usage: "/effort", description: "choose reasoning effort" },
20661
20747
  { name: "topics", usage: "/topics", description: "open topic picker" },
20748
+ { name: "public", usage: "/public", description: "share this topic with Otium Hub" },
20749
+ { name: "private", usage: "/private", description: "make this topic local-only" },
20662
20750
  {
20663
20751
  name: "fork",
20664
20752
  usage: "/fork [name]",
@@ -20854,8 +20942,8 @@ function orderTopicsByParent(topics) {
20854
20942
  }
20855
20943
  function setBackgroundSessions(state, backgroundSessions) {
20856
20944
  const orderedSessions = [
20857
- ...backgroundSessions.filter((session) => session.kind === "memory"),
20858
- ...backgroundSessions.filter((session) => session.kind === "cron")
20945
+ ...backgroundSessions.filter((session) => session.kind === "cron"),
20946
+ ...backgroundSessions.filter((session) => session.kind === "memory")
20859
20947
  ];
20860
20948
  const selectedStillExists = orderedSessions.some((session) => session.id === state.topicPickerBackgroundId);
20861
20949
  return {
@@ -20872,8 +20960,11 @@ function pickedTopic(state) {
20872
20960
  return state.topicPickerBackgroundId ? undefined : state.topics[state.topicPickerIndex];
20873
20961
  }
20874
20962
  function moveTopicPickerSelection(state, delta) {
20963
+ const indexedTopics = state.topics.map((topic, index) => ({ topic, index }));
20875
20964
  const items = [
20876
- ...state.topics.map((topic, index) => ({ kind: "topic", id: topic.id, index })),
20965
+ ...indexedTopics.filter(({ topic }) => topic.kind === "manager").map(({ topic, index }) => ({ kind: "topic", id: topic.id, index })),
20966
+ ...indexedTopics.filter(({ topic }) => topic.kind !== "manager" && topic.accessMode !== "shared").map(({ topic, index }) => ({ kind: "topic", id: topic.id, index })),
20967
+ ...indexedTopics.filter(({ topic }) => topic.kind !== "manager" && topic.accessMode === "shared").map(({ topic, index }) => ({ kind: "topic", id: topic.id, index })),
20877
20968
  ...state.backgroundSessions.map((session) => ({
20878
20969
  kind: "background",
20879
20970
  id: session.id
@@ -21510,7 +21601,8 @@ function helpLines() {
21510
21601
  line(" Esc/Ctrl-C stop active turn \xB7 Ctrl-C twice when idle to quit"),
21511
21602
  line(""),
21512
21603
  line(" Commands", { fg: theme.cyan, bold: true }),
21513
- line(" /new /model /effort /topics /fork /spawn /del /copy"),
21604
+ line(" /new /model /effort /topics /public /private"),
21605
+ line(" /fork /spawn /del /copy"),
21514
21606
  line(" /abort /help /quit", { fg: theme.muted })
21515
21607
  ];
21516
21608
  }
@@ -21561,23 +21653,27 @@ function plainTranscript(state) {
21561
21653
  }
21562
21654
  function topicOverlayLines(state, width, height, animationFrame = 0) {
21563
21655
  const indexedTopics = state.topics.map((topic, topicIndex) => ({ topic, topicIndex }));
21564
- const general = indexedTopics.filter(({ topic }) => topic.title.toLowerCase() === "general");
21565
- const otherTopics = indexedTopics.filter(({ topic }) => topic.title.toLowerCase() !== "general");
21656
+ const managerTopics = indexedTopics.filter(({ topic }) => topic.kind === "manager");
21657
+ const privateTopics = indexedTopics.filter(({ topic }) => topic.kind !== "manager" && topic.accessMode !== "shared");
21658
+ const publicTopics = indexedTopics.filter(({ topic }) => topic.kind !== "manager" && topic.accessMode === "shared");
21566
21659
  const entries = [];
21567
- if (general.length > 0) {
21568
- entries.push({ kind: "manager" }, ...general.map(({ topic, topicIndex }) => ({ kind: "topic", topic, topicIndex })));
21569
- }
21570
- if (otherTopics.length > 0) {
21571
- if (general.length > 0)
21660
+ for (const [label, topics] of [
21661
+ ["Manager", managerTopics],
21662
+ ["Private", privateTopics],
21663
+ ["Public", publicTopics]
21664
+ ]) {
21665
+ if (topics.length === 0)
21666
+ continue;
21667
+ if (entries.length > 0)
21572
21668
  entries.push({ kind: "separator" });
21573
- entries.push({ kind: "heading", label: "Worker" });
21574
- entries.push(...otherTopics.map(({ topic, topicIndex }) => ({
21669
+ entries.push({ kind: "heading", label });
21670
+ entries.push(...topics.map(({ topic, topicIndex }) => ({
21575
21671
  kind: "topic",
21576
21672
  topic,
21577
21673
  topicIndex
21578
21674
  })));
21579
21675
  }
21580
- for (const kind of ["memory", "cron"]) {
21676
+ for (const kind of ["cron", "memory"]) {
21581
21677
  const sessions = state.backgroundSessions.filter((session) => session.kind === kind);
21582
21678
  if (sessions.length === 0)
21583
21679
  continue;
@@ -21594,18 +21690,12 @@ function topicOverlayLines(state, width, height, animationFrame = 0) {
21594
21690
  }
21595
21691
  const visibleCount = Math.max(1, height - 3);
21596
21692
  const selectedEntryIndex = entries.findIndex((entry) => entry.kind === "topic" && !state.topicPickerBackgroundId && entry.topicIndex === state.topicPickerIndex || entry.kind === "background" && entry.sessionId === state.topicPickerBackgroundId);
21597
- let start = Math.min(Math.max(0, entries.length - visibleCount), Math.max(0, selectedEntryIndex - visibleCount + 1));
21598
- if (start > 0 && entries[start - 1]?.kind === "manager" && selectedEntryIndex - (start - 1) < visibleCount) {
21599
- start -= 1;
21600
- }
21693
+ const start = Math.min(Math.max(0, entries.length - visibleCount), Math.max(0, selectedEntryIndex - visibleCount + 1));
21601
21694
  return [
21602
21695
  line(" Topics", { fg: theme.accent, bold: true }),
21603
21696
  line(state.topicPickerRoot ? " \u2191\u2193 select \xB7 Enter open \xB7 N new \xB7 D/Del delete \xB7 Esc/Ctrl-C exit" : " \u2191\u2193 select \xB7 Enter open \xB7 N new \xB7 D/Del delete \xB7 Esc close \xB7 Ctrl-C exit; work continues", { fg: theme.muted }),
21604
21697
  line(""),
21605
21698
  ...entries.length === 0 ? [line(" No topics yet \xB7 Press N to create one", { fg: theme.muted })] : entries.slice(start, start + visibleCount).map((entry) => {
21606
- if (entry.kind === "manager") {
21607
- return line(" Manager", { fg: theme.cyan, bold: true });
21608
- }
21609
21699
  if (entry.kind === "heading") {
21610
21700
  return line(` ${entry.label}`, { fg: theme.cyan, bold: true });
21611
21701
  }
@@ -21993,7 +22083,7 @@ function renderAppFrame(state, columns, rows, animationFrame = 0, nowMs = termin
21993
22083
  const height = Math.max(14, rows);
21994
22084
  const footer = footerLines(state, width);
21995
22085
  const decision = decisionPane(state, width);
21996
- const hideComposer = state.overlay === "background-session" || state.overlay === "vault" && (state.vaultMode === "list" || state.vaultMode === "confirm-delete");
22086
+ const hideComposer = state.overlay === "background-session" || state.overlay === "topics" || state.overlay === "vault" && (state.vaultMode === "list" || state.vaultMode === "confirm-delete");
21997
22087
  const composer = hideComposer ? { lines: [], cursor: null } : composerPane(state, width);
21998
22088
  const bodyHeight = Math.max(3, height - footer.length - decision.length - composer.lines.length);
21999
22089
  const body = renderBody(conversationLines(state, width, bodyHeight, animationFrame, nowMs), width, bodyHeight);
@@ -23249,6 +23339,31 @@ class TerminalApp {
23249
23339
  this.#queueRender();
23250
23340
  return;
23251
23341
  }
23342
+ if (command === "public" || command === "private") {
23343
+ if (args.length > 0) {
23344
+ this.#state = { ...this.#state, notice: `Usage: /${command}` };
23345
+ this.#queueRender();
23346
+ return;
23347
+ }
23348
+ const topic = activeTopic(this.#state);
23349
+ if (!topic) {
23350
+ this.#state = { ...this.#state, notice: "No topic selected" };
23351
+ this.#queueRender();
23352
+ return;
23353
+ }
23354
+ try {
23355
+ const notice = await this.#client.setAccessMode(topic, command === "public" ? "shared" : "private");
23356
+ await this.#refreshTopics(topic.title);
23357
+ this.#state = { ...this.#state, notice };
23358
+ } catch (error) {
23359
+ this.#state = {
23360
+ ...this.#state,
23361
+ notice: error instanceof Error ? error.message : String(error)
23362
+ };
23363
+ }
23364
+ this.#queueRender();
23365
+ return;
23366
+ }
23252
23367
  if (command === "compact") {
23253
23368
  const topic = activeTopic(this.#state);
23254
23369
  if (!topic) {
@@ -25688,13 +25803,25 @@ Warning: enable the bot administrator permission "Manage Topics" before creating
25688
25803
  const rememberTarget = (queryId2) => {
25689
25804
  targetByQueryId.set(queryId2, target);
25690
25805
  };
25691
- const { queryId } = submitUserMessage({
25806
+ const input = {
25692
25807
  topic,
25693
25808
  userId,
25694
25809
  text: prompt,
25695
25810
  sourceAdapter: "telegram",
25696
25811
  visualTools: false,
25697
- fileDeliveryTools: true,
25812
+ fileDeliveryTools: true
25813
+ };
25814
+ if (opts.submitTurn) {
25815
+ opts.submitTurn(input).then(({ queryId: queryId2 }) => {
25816
+ if (queryId2)
25817
+ rememberTarget(queryId2);
25818
+ }).catch((error) => {
25819
+ logger.error({ err: error, topicId: topic.id }, "telegram adapter: remote turn failed");
25820
+ });
25821
+ return;
25822
+ }
25823
+ const { queryId } = submitUserMessage({
25824
+ ...input,
25698
25825
  onDispatched: rememberTarget,
25699
25826
  startTurn: dispatchTurn
25700
25827
  });
@@ -25883,7 +26010,8 @@ ${caption}` : voiceText : caption;
25883
26010
  }
25884
26011
  case "/abort": {
25885
26012
  const mapping = byKey.get(mappingKey(chatId, threadId));
25886
- reply(chatId, threadId, mapping && topicService.abortTurn(mapping.topicId, userId) ? "aborted" : "nothing running");
26013
+ const aborted = mapping ? await (opts.abortTurn?.(mapping.topicId, userId) ?? topicService.abortTurn(mapping.topicId, userId)) : false;
26014
+ reply(chatId, threadId, aborted ? "aborted" : "nothing running");
25887
26015
  return;
25888
26016
  }
25889
26017
  case "/agent": {
@@ -26260,7 +26388,7 @@ __export(exports_cli2, {
26260
26388
  runTelegramCli: () => runTelegramCli
26261
26389
  });
26262
26390
  import TelegramBot from "node-telegram-bot-api";
26263
- function startTelegramFromEnv() {
26391
+ function startTelegramFromEnv(options = {}) {
26264
26392
  const token = process.env.TELEGRAM_BOT_TOKEN?.trim();
26265
26393
  if (!token)
26266
26394
  throw new Error("TELEGRAM_BOT_TOKEN is required");
@@ -26268,6 +26396,7 @@ function startTelegramFromEnv() {
26268
26396
  const requestedAgent = process.env.TELEGRAM_DEFAULT_AGENT?.trim();
26269
26397
  const forumChatId = Number.parseInt(process.env.TELEGRAM_FORUM_CHAT_ID ?? "", 10);
26270
26398
  const adapter = startTelegramAdapter({
26399
+ ...options,
26271
26400
  client: bot,
26272
26401
  allowedUsers: (process.env.TELEGRAM_ALLOWED_USERS ?? "").split(",").map((value) => value.trim()).filter(Boolean),
26273
26402
  ...process.env.TELEGRAM_VAULT_OWNER_USER_ID?.trim() ? { vaultOwnerTelegramUserId: process.env.TELEGRAM_VAULT_OWNER_USER_ID.trim() } : {},
@@ -26288,38 +26417,101 @@ function startTelegramFromEnv() {
26288
26417
  }
26289
26418
  };
26290
26419
  }
26291
- async function runTelegramCli() {
26420
+ async function spawnCanonicalNode() {
26421
+ const entry = process.argv[1];
26422
+ if (!entry)
26423
+ throw new Error("cannot locate the Negotium CLI entrypoint");
26424
+ const child = Bun.spawn({
26425
+ cmd: [process.execPath, entry, "__node-daemon", "--port=0"],
26426
+ detached: true,
26427
+ env: { ...process.env, LOG_LEVEL: process.env.NEGOTIUM_NODE_LOG_LEVEL?.trim() || "info" },
26428
+ stdin: "ignore",
26429
+ stdout: "ignore",
26430
+ stderr: Bun.file(`${LOG_DIR}/node-daemon.log`)
26431
+ });
26432
+ child.unref();
26433
+ }
26434
+ async function ensureCanonicalNode() {
26435
+ const status = await inspectNodeDaemon();
26436
+ if (!status.running)
26437
+ await spawnCanonicalNode();
26438
+ return waitForNodeDaemon(15000);
26439
+ }
26440
+ async function runCanonicalNodeChild() {
26441
+ const { runNodeDaemon: runNodeDaemon2 } = await init_src5().then(() => exports_src3);
26442
+ await runNodeDaemon2({ port: 0 });
26443
+ }
26444
+ async function runTelegramCli(args = process.argv.slice(2)) {
26445
+ if (args[0] === "__node-daemon") {
26446
+ await runCanonicalNodeChild();
26447
+ return;
26448
+ }
26292
26449
  if (!process.env.TELEGRAM_BOT_TOKEN?.trim()) {
26293
26450
  throw new Error("TELEGRAM_BOT_TOKEN is required");
26294
26451
  }
26295
- let node;
26452
+ const initialNode = await ensureCanonicalNode();
26296
26453
  const singleton = await waitForRequiredRuntimeProcessLease("adapter:telegram", {
26297
26454
  workloadName: "Telegram adapter",
26298
26455
  onLost: () => {
26299
26456
  process.stderr.write(`negotium-telegram: singleton lease lost; shutting down
26300
26457
  `);
26301
- node?.stop();
26458
+ runShutdown("test");
26302
26459
  }
26303
26460
  });
26304
- try {
26305
- node = await startDefaultNode({ port: 0 });
26306
- } catch (error) {
26307
- singleton.stop();
26308
- throw error;
26309
- }
26310
26461
  let channel;
26311
26462
  try {
26312
- channel = startTelegramFromEnv();
26463
+ channel = startTelegramFromEnv({
26464
+ abortTurn: async (topicId, userId) => {
26465
+ const node = await waitForNodeDaemon(1500);
26466
+ const response = await fetch(`${node.baseUrl}${NODE_CONTROL_BASE_PATH}/topics/${encodeURIComponent(topicId)}/abort`, {
26467
+ method: "POST",
26468
+ headers: {
26469
+ authorization: `Bearer ${NODE_CONTROL_TOKEN}`,
26470
+ "content-type": "application/json"
26471
+ },
26472
+ body: JSON.stringify({ userId })
26473
+ });
26474
+ const body = await response.json();
26475
+ if (!response.ok)
26476
+ throw new Error(body.error ?? `node returned HTTP ${response.status}`);
26477
+ return body.aborted === true;
26478
+ },
26479
+ submitTurn: async (input) => {
26480
+ const node = await waitForNodeDaemon(1500);
26481
+ const response = await fetch(`${node.baseUrl}${NODE_CONTROL_BASE_PATH}/topics/${encodeURIComponent(input.topic.id)}/messages`, {
26482
+ method: "POST",
26483
+ headers: {
26484
+ authorization: `Bearer ${NODE_CONTROL_TOKEN}`,
26485
+ "content-type": "application/json"
26486
+ },
26487
+ body: JSON.stringify({
26488
+ userId: input.userId,
26489
+ text: input.text,
26490
+ sourceAdapter: input.sourceAdapter,
26491
+ visualTools: input.visualTools,
26492
+ fileDeliveryTools: input.fileDeliveryTools
26493
+ })
26494
+ });
26495
+ const body = await response.json();
26496
+ if (!response.ok)
26497
+ throw new Error(body.error ?? `node returned HTTP ${response.status}`);
26498
+ return { queryId: body.queryId };
26499
+ }
26500
+ });
26313
26501
  } catch (error) {
26314
- await node.stop();
26315
26502
  singleton.stop();
26316
26503
  throw error;
26317
26504
  }
26318
26505
  onShutdown("telegram-channel", 100, () => channel.stop());
26319
26506
  onShutdown("telegram-singleton", 90, () => singleton.stop());
26320
- process.stdout.write(`negotium Telegram adapter listening through node :${node.port}
26507
+ let resolveCompleted;
26508
+ const completed = new Promise((resolve13) => {
26509
+ resolveCompleted = resolve13;
26510
+ });
26511
+ onShutdown("telegram-completed", -100, resolveCompleted);
26512
+ process.stdout.write(`negotium Telegram adapter connected to canonical node pid ${initialNode.info?.pid ?? "unknown"}
26321
26513
  `);
26322
- await node.completed;
26514
+ await completed;
26323
26515
  }
26324
26516
  var init_cli2 = __esm(async () => {
26325
26517
  await init_src();
@@ -26328,37 +26520,6 @@ var init_cli2 = __esm(async () => {
26328
26520
  if (false) {}
26329
26521
  });
26330
26522
 
26331
- // ../../adapters/otium/src/protocol.ts
26332
- function str(body, field) {
26333
- const value = body[field];
26334
- return typeof value === "string" && value.trim() ? value : null;
26335
- }
26336
- function parseExecutionSpec(value) {
26337
- if (!value || typeof value !== "object" || Array.isArray(value))
26338
- return null;
26339
- const raw = value;
26340
- const agent = str(raw, "agent");
26341
- const model = str(raw, "model");
26342
- const effort = str(raw, "effort");
26343
- const rawMcp = raw.mcp;
26344
- if (!agent || !model || !effort || !Array.isArray(rawMcp) || !rawMcp.every((entry) => typeof entry === "string" && entry.trim().length > 0) || typeof raw.canSpawnSubagents !== "boolean") {
26345
- return null;
26346
- }
26347
- return {
26348
- agent,
26349
- model,
26350
- effort,
26351
- ...str(raw, "description") ? { description: str(raw, "description") } : {},
26352
- mcp: [...new Set(rawMcp.map((entry) => entry.trim()))],
26353
- canSpawnSubagents: raw.canSpawnSubagents
26354
- };
26355
- }
26356
- var PEER_PROTOCOL_VERSION = 1, MAX_PEER_MESSAGE_LENGTH = 1e4, MAX_PEER_INPUT_FILE_BYTES, MAX_PEER_INPUT_REQUEST_BYTES;
26357
- var init_protocol = __esm(() => {
26358
- MAX_PEER_INPUT_FILE_BYTES = 2 * 1024 * 1024 * 1024;
26359
- MAX_PEER_INPUT_REQUEST_BYTES = MAX_PEER_INPUT_FILE_BYTES + 8 * 1024 * 1024;
26360
- });
26361
-
26362
26523
  // ../../adapters/otium/src/central.ts
26363
26524
  var exports_central = {};
26364
26525
  __export(exports_central, {
@@ -26518,6 +26679,7 @@ __export(exports_join, {
26518
26679
  withJoinCredentialLock: () => withJoinCredentialLock,
26519
26680
  saveJoinWhileLocked: () => saveJoinWhileLocked,
26520
26681
  saveJoin: () => saveJoin,
26682
+ removeJoin: () => removeJoin,
26521
26683
  parseInviteCode: () => parseInviteCode,
26522
26684
  loadJoin: () => loadJoin,
26523
26685
  joinFilePath: () => joinFilePath,
@@ -26778,6 +26940,24 @@ function saveJoinWhileLocked(join31, options = {}) {
26778
26940
  function saveJoin(join31, options = {}) {
26779
26941
  return withJoinCredentialLock(() => saveJoinWhileLocked(join31, options));
26780
26942
  }
26943
+ function removeJoin() {
26944
+ return withJoinCredentialLock(() => {
26945
+ const path = joinFilePath();
26946
+ if (!existsSync25(path))
26947
+ return false;
26948
+ if (lstatSync(path).isSymbolicLink()) {
26949
+ throw new Error(`refusing to remove symlinked Otium join file at ${path}`);
26950
+ }
26951
+ unlinkSync18(path);
26952
+ const directoryFd = openSync3(dirname17(path), "r");
26953
+ try {
26954
+ fsyncSync2(directoryFd);
26955
+ } finally {
26956
+ closeSync3(directoryFd);
26957
+ }
26958
+ return true;
26959
+ });
26960
+ }
26781
26961
  function loadJoin() {
26782
26962
  const central = process.env.OTIUM_CENTRAL_URL?.trim();
26783
26963
  const cellId = process.env.OTIUM_CELL_ID?.trim();
@@ -26812,363 +26992,56 @@ var init_join = __esm(async () => {
26812
26992
  await init_src();
26813
26993
  });
26814
26994
 
26815
- // ../../adapters/otium/src/enrollment.ts
26816
- import {
26817
- createDecipheriv as createDecipheriv2,
26818
- createPrivateKey,
26819
- createPublicKey,
26820
- diffieHellman,
26821
- generateKeyPairSync,
26822
- hkdfSync,
26823
- randomUUID as randomUUID24
26824
- } from "crypto";
26825
- import {
26826
- chmodSync as chmodSync6,
26827
- closeSync as closeSync4,
26828
- existsSync as existsSync26,
26829
- fsyncSync as fsyncSync3,
26830
- linkSync as linkSync2,
26831
- mkdirSync as mkdirSync29,
26832
- openSync as openSync4,
26833
- readFileSync as readFileSync21,
26834
- renameSync as renameSync14,
26835
- unlinkSync as unlinkSync19,
26836
- writeFileSync as writeFileSync19
26837
- } from "fs";
26838
- import { dirname as dirname18, resolve as resolve14 } from "path";
26839
- function parseEnrollmentInvite(code) {
26840
- let parsed;
26841
- try {
26842
- parsed = JSON.parse(Buffer.from(code.trim(), "base64url").toString("utf8"));
26843
- } catch {
26844
- throw new Error("production invite must be base64url JSON");
26845
- }
26846
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
26847
- throw new Error("production invite must decode to an object");
26848
- }
26849
- const raw = parsed;
26850
- const central = typeof raw.central === "string" ? raw.central.trim().replace(/\/+$/, "") : "";
26851
- const token = typeof raw.token === "string" ? raw.token.trim() : "";
26852
- if (raw.v !== 2 || !central || !token.startsWith("nei_")) {
26853
- throw new Error("production invite requires {v:2, central:http(s), token:nei_\u2026}");
26995
+ // ../../adapters/otium/src/control-protocol.ts
26996
+ var OTIUM_ADAPTER_CONTROL_PREFIX = "/api/v1/adapter/otium", OTIUM_ADAPTER_CONTROL_HEADER = "x-negotium-adapter-token";
26997
+
26998
+ // ../../adapters/otium/src/protocol.ts
26999
+ function str(body, field) {
27000
+ const value = body[field];
27001
+ return typeof value === "string" && value.trim() ? value : null;
27002
+ }
27003
+ function parseExecutionSpec(value) {
27004
+ if (!value || typeof value !== "object" || Array.isArray(value))
27005
+ return null;
27006
+ const raw = value;
27007
+ const agent = str(raw, "agent");
27008
+ const model = str(raw, "model");
27009
+ const effort = str(raw, "effort");
27010
+ const rawMcp = raw.mcp;
27011
+ if (!agent || !model || !effort || !Array.isArray(rawMcp) || !rawMcp.every((entry) => typeof entry === "string" && entry.trim().length > 0) || typeof raw.canSpawnSubagents !== "boolean") {
27012
+ return null;
26854
27013
  }
26855
- assertSecureCentralUrl(central);
26856
- return { v: 2, central, token };
27014
+ return {
27015
+ agent,
27016
+ model,
27017
+ effort,
27018
+ ...str(raw, "description") ? { description: str(raw, "description") } : {},
27019
+ mcp: [...new Set(rawMcp.map((entry) => entry.trim()))],
27020
+ canSpawnSubagents: raw.canSpawnSubagents
27021
+ };
26857
27022
  }
26858
- function pendingEnrollmentPath() {
26859
- return resolve14(DATA_DIR, "otium-enrollment-pending.json");
27023
+ var PEER_PROTOCOL_VERSION = 1, MAX_PEER_MESSAGE_LENGTH = 1e4, MAX_PEER_INPUT_FILE_BYTES, MAX_PEER_INPUT_REQUEST_BYTES;
27024
+ var init_protocol = __esm(() => {
27025
+ MAX_PEER_INPUT_FILE_BYTES = 2 * 1024 * 1024 * 1024;
27026
+ MAX_PEER_INPUT_REQUEST_BYTES = MAX_PEER_INPUT_FILE_BYTES + 8 * 1024 * 1024;
27027
+ });
27028
+
27029
+ // ../../adapters/otium/src/canonical-mcp-bridge.ts
27030
+ import { randomUUID as randomUUID24 } from "crypto";
27031
+ function readAuthorization(request) {
27032
+ const value = request.headers.get("authorization");
27033
+ return value?.startsWith("Bearer ") ? value.slice(7) : null;
26860
27034
  }
26861
- function isEnrollmentPending(invite) {
26862
- const path = pendingEnrollmentPath();
26863
- if (!existsSync26(path))
26864
- return false;
26865
- try {
26866
- const saved = JSON.parse(readFileSync21(path, "utf8"));
26867
- return saved.central === invite.central && saved.token === invite.token;
26868
- } catch {
26869
- return false;
26870
- }
26871
- }
26872
- function loadOrCreatePending(invite, nodeName) {
26873
- const path = pendingEnrollmentPath();
26874
- if (existsSync26(path)) {
26875
- const saved = JSON.parse(readFileSync21(path, "utf8"));
26876
- if (saved.central === invite.central && saved.token === invite.token)
26877
- return saved;
26878
- throw new Error(`another Otium enrollment is pending at ${path}`);
26879
- }
26880
- const pair = generateKeyPairSync("x25519");
26881
- const pending2 = {
26882
- ...invite,
26883
- idempotencyKey: randomUUID24(),
26884
- privateKey: pair.privateKey.export({ format: "der", type: "pkcs8" }).toString("base64url"),
26885
- publicKey: pair.publicKey.export({ format: "der", type: "spki" }).toString("base64url"),
26886
- ...nodeName ? { nodeName } : {}
26887
- };
26888
- mkdirSync29(dirname18(path), { recursive: true });
26889
- const temporaryPath = resolve14(dirname18(path), `.otium-enrollment-pending.json.${process.pid}.${randomUUID24()}.tmp`);
26890
- let fd;
26891
- try {
26892
- fd = openSync4(temporaryPath, "wx", 384);
26893
- writeFileSync19(fd, `${JSON.stringify(pending2, null, 2)}
26894
- `, "utf8");
26895
- fsyncSync3(fd);
26896
- closeSync4(fd);
26897
- fd = undefined;
26898
- linkSync2(temporaryPath, path);
26899
- unlinkSync19(temporaryPath);
26900
- chmodSync6(path, 384);
26901
- const directoryFd = openSync4(dirname18(path), "r");
26902
- try {
26903
- fsyncSync3(directoryFd);
26904
- } finally {
26905
- closeSync4(directoryFd);
26906
- }
26907
- } catch (error) {
26908
- if (fd !== undefined)
26909
- closeSync4(fd);
26910
- if (existsSync26(temporaryPath))
26911
- unlinkSync19(temporaryPath);
26912
- if (error.code === "EEXIST" && existsSync26(path)) {
26913
- const saved = JSON.parse(readFileSync21(path, "utf8"));
26914
- if (saved.central === invite.central && saved.token === invite.token)
26915
- return saved;
26916
- }
26917
- throw error;
26918
- }
26919
- return pending2;
26920
- }
26921
- function replacePendingEnrollment(pending2) {
26922
- const path = pendingEnrollmentPath();
26923
- const directory = dirname18(path);
26924
- const temporaryPath = resolve14(directory, `.otium-enrollment-pending.json.${process.pid}.${randomUUID24()}.tmp`);
26925
- let fd;
26926
- try {
26927
- fd = openSync4(temporaryPath, "wx", 384);
26928
- writeFileSync19(fd, `${JSON.stringify(pending2, null, 2)}
26929
- `, "utf8");
26930
- fsyncSync3(fd);
26931
- closeSync4(fd);
26932
- fd = undefined;
26933
- renameSync14(temporaryPath, path);
26934
- const directoryFd = openSync4(directory, "r");
26935
- try {
26936
- fsyncSync3(directoryFd);
26937
- } finally {
26938
- closeSync4(directoryFd);
26939
- }
26940
- } catch (error) {
26941
- if (fd !== undefined)
26942
- closeSync4(fd);
26943
- if (existsSync26(temporaryPath))
26944
- unlinkSync19(temporaryPath);
26945
- throw error;
26946
- }
26947
- }
26948
- function recordClaimedCredential(pending2, join31) {
26949
- withJoinCredentialLock(() => {
26950
- const current3 = JSON.parse(readFileSync21(pendingEnrollmentPath(), "utf8"));
26951
- if (current3.central !== pending2.central || current3.token !== pending2.token || current3.idempotencyKey !== pending2.idempotencyKey || current3.publicKey !== pending2.publicKey) {
26952
- throw new Error("pending Otium enrollment changed while its claim was in flight");
26953
- }
26954
- const claimed = { digest: joinCredentialDigest(join31), cellId: join31.cellId };
26955
- if (current3.claimed && (current3.claimed.digest !== claimed.digest || current3.claimed.cellId !== claimed.cellId)) {
26956
- throw new Error("central returned different credentials for an idempotent enrollment claim");
26957
- }
26958
- replacePendingEnrollment({ ...current3, claimed });
26959
- });
26960
- }
26961
- function openCredential(envelope, privateKeyDer) {
26962
- if (envelope.v !== 1 || envelope.algorithm !== "X25519-HKDF-SHA256-AES-256-GCM") {
26963
- throw new Error("unsupported enrollment credential envelope");
26964
- }
26965
- const privateKey = createPrivateKey({
26966
- key: Buffer.from(privateKeyDer, "base64url"),
26967
- format: "der",
26968
- type: "pkcs8"
26969
- });
26970
- const ephemeral = createPublicKey({
26971
- key: Buffer.from(envelope.ephemeralPublicKey, "base64url"),
26972
- format: "der",
26973
- type: "spki"
26974
- });
26975
- const shared = diffieHellman({ privateKey, publicKey: ephemeral });
26976
- const key = Buffer.from(hkdfSync("sha256", shared, Buffer.from(envelope.salt, "base64url"), INFO, 32));
26977
- const decipher = createDecipheriv2("aes-256-gcm", key, Buffer.from(envelope.nonce, "base64url"));
26978
- decipher.setAAD(INFO);
26979
- decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
26980
- return Buffer.concat([
26981
- decipher.update(Buffer.from(envelope.ciphertext, "base64url")),
26982
- decipher.final()
26983
- ]).toString("utf8");
26984
- }
26985
- async function postJson(url, body, headers) {
26986
- const response = await fetch(url, {
26987
- method: "POST",
26988
- headers: { "content-type": "application/json", ...headers },
26989
- body: JSON.stringify(body)
26990
- });
26991
- const result = await response.json();
26992
- if (!response.ok)
26993
- throw new Error(String(result.error || `request failed (${response.status})`));
26994
- return result;
26995
- }
26996
- async function previewEnrollment(invite) {
26997
- return postJson(`${invite.central}/api/v1/node-enrollments/preview`, {
26998
- token: invite.token
26999
- });
27000
- }
27001
- async function claimEnrollment(invite, nodeName) {
27002
- const pending2 = loadOrCreatePending(invite, nodeName);
27003
- const response = await postJson(`${invite.central}/api/v1/node-enrollments/claim`, {
27004
- token: invite.token,
27005
- credentialPublicKey: pending2.publicKey,
27006
- ...pending2.nodeName ? { nodeName: pending2.nodeName } : {}
27007
- }, { "idempotency-key": pending2.idempotencyKey });
27008
- const credential = response.credential;
27009
- const secret = openCredential(credential, pending2.privateKey);
27010
- if (!secret.startsWith("rcs_"))
27011
- throw new Error("central returned an invalid runtime credential");
27012
- const join31 = {
27013
- v: 2,
27014
- central: invite.central,
27015
- relay: String(response.relayUrl),
27016
- cellId: String(response.cell?.id),
27017
- secret
27018
- };
27019
- if (!join31.relay || !join31.cellId || join31.cellId === "undefined") {
27020
- throw new Error("central returned an incomplete enrollment response");
27021
- }
27022
- assertSecureRelayUrl(join31.relay);
27023
- recordClaimedCredential(pending2, join31);
27024
- return join31;
27025
- }
27026
- function commitEnrollment(join31, options = {}) {
27027
- return withJoinCredentialLock(() => {
27028
- const pendingPath = pendingEnrollmentPath();
27029
- const pending2 = existsSync26(pendingPath) ? JSON.parse(readFileSync21(pendingPath, "utf8")) : null;
27030
- const digest = joinCredentialDigest(join31);
27031
- if (pending2 && (!pending2.claimed || pending2.claimed.digest !== digest || pending2.claimed.cellId !== join31.cellId)) {
27032
- throw new Error(`pending Otium enrollment at ${pendingPath} does not match these credentials`);
27033
- }
27034
- const path = saveJoinWhileLocked(join31, options);
27035
- if (!isJoinPersisted(join31)) {
27036
- throw new Error("Otium join credentials were not durably persisted");
27037
- }
27038
- if (!pending2)
27039
- return path;
27040
- unlinkSync19(pendingPath);
27041
- const directoryFd = openSync4(dirname18(pendingPath), "r");
27042
- try {
27043
- fsyncSync3(directoryFd);
27044
- } finally {
27045
- closeSync4(directoryFd);
27046
- }
27047
- return path;
27048
- });
27049
- }
27050
- var INFO;
27051
- var init_enrollment = __esm(async () => {
27052
- await init_src();
27053
- await init_join();
27054
- INFO = Buffer.from("otium-node-enrollment-v1", "utf8");
27055
- });
27056
-
27057
- // ../../adapters/otium/src/join-cli.ts
27058
- var exports_join_cli = {};
27059
- __export(exports_join_cli, {
27060
- joinCommand: () => joinCommand
27061
- });
27062
- function option2(args, name) {
27063
- const index = args.indexOf(`--${name}`);
27064
- return index >= 0 ? args[index + 1]?.trim() : undefined;
27065
- }
27066
- async function confirmEnrollment(message) {
27067
- if (!process.stdin.isTTY)
27068
- return false;
27069
- const { createInterface } = await import("readline/promises");
27070
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
27071
- try {
27072
- return /^(?:y|yes)$/i.test((await prompt.question(`${message} [y/N] `)).trim());
27073
- } finally {
27074
- prompt.close();
27075
- }
27076
- }
27077
- async function joinCommand(args) {
27078
- const code = args[0]?.trim();
27079
- if (!code) {
27080
- console.error("usage: negotium-otium join <invite-code> [--yes] [--name <node-name>] [--legacy] [--replace]");
27081
- process.exitCode = 1;
27082
- return;
27083
- }
27084
- let join31;
27085
- let productionEnrollment = false;
27086
- if (args.includes("--legacy")) {
27087
- try {
27088
- join31 = parseInviteCode(code);
27089
- } catch (err2) {
27090
- console.error(`invalid legacy invite code: ${err2 instanceof Error ? err2.message : err2}`);
27091
- process.exitCode = 1;
27092
- return;
27093
- }
27094
- } else {
27095
- try {
27096
- const invite = parseEnrollmentInvite(code);
27097
- const resuming = isEnrollmentPending(invite);
27098
- let nodeName = option2(args, "name");
27099
- if (resuming) {
27100
- console.log(`Resuming interrupted Otium enrollment with ${invite.central}`);
27101
- } else {
27102
- const preview = await previewEnrollment(invite);
27103
- const workspace = preview.preview?.workspace;
27104
- nodeName ||= preview.preview?.suggestedNodeName || undefined;
27105
- console.log(`Otium workspace: ${workspace?.name ?? workspace?.slug ?? workspace?.id}`);
27106
- console.log(` central: ${invite.central}`);
27107
- console.log(` transport: ${preview.preview?.transport ?? "relay"}`);
27108
- console.log(` access: ${preview.preview?.topics ?? "explicitly shared topics only"}`);
27109
- const accepted = args.includes("--yes") || await confirmEnrollment("Join this workspace?");
27110
- if (!accepted) {
27111
- console.log("enrollment cancelled");
27112
- return;
27113
- }
27114
- }
27115
- join31 = await claimEnrollment(invite, nodeName);
27116
- productionEnrollment = true;
27117
- } catch (err2) {
27118
- console.error(`enrollment failed: ${err2 instanceof Error ? err2.message : err2}`);
27119
- process.exitCode = 1;
27120
- return;
27121
- }
27122
- }
27123
- let path;
27124
- try {
27125
- const saveOptions = { replaceExisting: args.includes("--replace") };
27126
- path = productionEnrollment ? commitEnrollment(join31, saveOptions) : saveJoin(join31, saveOptions);
27127
- } catch (err2) {
27128
- console.error(`could not save join credentials: ${err2 instanceof Error ? err2.message : err2}`);
27129
- process.exitCode = 1;
27130
- return;
27131
- }
27132
- console.log(`otium join credentials saved to ${path}`);
27133
- console.log(` central: ${join31.central}`);
27134
- console.log(` cellId: ${join31.cellId}`);
27135
- configureOtiumCentral(join31);
27136
- try {
27137
- const self = await selfPeerNode();
27138
- if (self) {
27139
- console.log(`attached to workspace as "${self.nodeName ?? self.cellId}" (baseUrl ${self.baseUrl})`);
27140
- } else {
27141
- console.warn("warning: central answered but this cell has no visible assignment yet \u2014 check the workspace assignment");
27142
- }
27143
- } catch (err2) {
27144
- console.warn(`warning: could not verify against central (${err2 instanceof Error ? err2.message : err2}) \u2014 credentials saved anyway`);
27145
- } finally {
27146
- configureOtiumCentral(null);
27147
- }
27148
- console.log("\nnext: `negotium-otium serve` (mounts the otium peer routes automatically)");
27149
- }
27150
- var init_join_cli = __esm(async () => {
27151
- await init_central();
27152
- await init_enrollment();
27153
- await init_join();
27154
- });
27155
-
27156
- // ../../adapters/otium/src/canonical-mcp-bridge.ts
27157
- import { randomUUID as randomUUID25 } from "crypto";
27158
- function readAuthorization(request) {
27159
- const value = request.headers.get("authorization");
27160
- return value?.startsWith("Bearer ") ? value.slice(7) : null;
27161
- }
27162
- async function readLimitedJson(request) {
27163
- const declared = Number(request.headers.get("content-length") ?? "0");
27164
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
27165
- return null;
27166
- const reader = request.body?.getReader();
27167
- if (!reader)
27168
- return null;
27169
- const chunks = [];
27170
- let size = 0;
27171
- const deadline = Date.now() + BODY_READ_TIMEOUT_MS;
27035
+ async function readLimitedJson(request) {
27036
+ const declared = Number(request.headers.get("content-length") ?? "0");
27037
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
27038
+ return null;
27039
+ const reader = request.body?.getReader();
27040
+ if (!reader)
27041
+ return null;
27042
+ const chunks = [];
27043
+ let size = 0;
27044
+ const deadline = Date.now() + BODY_READ_TIMEOUT_MS;
27172
27045
  try {
27173
27046
  while (true) {
27174
27047
  const remaining = deadline - Date.now();
@@ -27295,7 +27168,7 @@ function startCanonicalMcpBridge(options = {}) {
27295
27168
  const url = `http://127.0.0.1:${server.port}/`;
27296
27169
  const unregister = registerCanonicalMcpBridgeEnvProvider((scope) => {
27297
27170
  sweep();
27298
- const token = `${randomUUID25()}${randomUUID25()}`;
27171
+ const token = `${randomUUID24()}${randomUUID24()}`;
27299
27172
  capabilities.set(token, {
27300
27173
  surface: scope.surface,
27301
27174
  userId: scope.userId,
@@ -27348,6 +27221,43 @@ var init_canonical_mcp_bridge = __esm(async () => {
27348
27221
 
27349
27222
  // ../../adapters/otium/src/store.ts
27350
27223
  import { createHash as createHash7 } from "crypto";
27224
+ function isPeerDetached(hubNodeId) {
27225
+ const row = db.query("SELECT status FROM otium_peer_lifecycle WHERE hub_node_id = ?").get(hubNodeId);
27226
+ return row?.status === "detached";
27227
+ }
27228
+ function enqueueSharedMessage(args) {
27229
+ db.run(`INSERT OR REPLACE INTO otium_shared_message_outbox
27230
+ (local_topic_id, source_message_id, message_json, created_at)
27231
+ VALUES (?, ?, ?, ?)`, [
27232
+ args.localTopicId,
27233
+ args.sourceMessageId,
27234
+ JSON.stringify(args.message),
27235
+ new Date().toISOString()
27236
+ ]);
27237
+ }
27238
+ function listSharedMessages(localTopicId) {
27239
+ return db.query("SELECT * FROM otium_shared_message_outbox WHERE local_topic_id = ? ORDER BY created_at").all(localTopicId);
27240
+ }
27241
+ function deleteSharedMessage(localTopicId, sourceMessageId) {
27242
+ return db.run("DELETE FROM otium_shared_message_outbox WHERE local_topic_id = ? AND source_message_id = ?", [localTopicId, sourceMessageId]).changes === 1;
27243
+ }
27244
+ function getSharedTopicState(localTopicId) {
27245
+ return db.query("SELECT * FROM otium_shared_topic_state WHERE local_topic_id = ?").get(localTopicId) ?? null;
27246
+ }
27247
+ function listSharedTopicStates() {
27248
+ return db.query("SELECT * FROM otium_shared_topic_state ORDER BY updated_at").all();
27249
+ }
27250
+ function setSharedTopicState(args) {
27251
+ db.run(`INSERT INTO otium_shared_topic_state (local_topic_id, host_topic_id, status, updated_at)
27252
+ VALUES (?, ?, ?, ?)
27253
+ ON CONFLICT(local_topic_id) DO UPDATE SET
27254
+ host_topic_id = excluded.host_topic_id,
27255
+ status = excluded.status,
27256
+ updated_at = excluded.updated_at`, [args.localTopicId, args.hostTopicId ?? null, args.status, new Date().toISOString()]);
27257
+ }
27258
+ function deleteSharedTopicState(localTopicId) {
27259
+ return db.run("DELETE FROM otium_shared_topic_state WHERE local_topic_id = ?", [localTopicId]).changes === 1;
27260
+ }
27351
27261
  function getPeerSession(hostNodeId, hostTopicId) {
27352
27262
  return db.query("SELECT * FROM otium_peer_sessions WHERE host_node_id = ? AND host_topic_id = ?").get(hostNodeId, hostTopicId) ?? null;
27353
27263
  }
@@ -27388,6 +27298,19 @@ function unbindPeerSession(hostNodeId, hostTopicId) {
27388
27298
  function unbindSharedPeerSessionsForLocalTopic(localTopicId) {
27389
27299
  return db.run("DELETE FROM otium_peer_sessions WHERE local_topic_id = ? AND binding_mode = 'shared'", [localTopicId]).changes;
27390
27300
  }
27301
+ function downgradeSharedTopicsLocally(hubNodeId) {
27302
+ return db.transaction(() => {
27303
+ const topics = db.query("SELECT id FROM api_topics WHERE access_mode = 'shared'").all().map((row) => row.id);
27304
+ db.run("UPDATE api_topics SET access_mode = 'private' WHERE access_mode = 'shared'");
27305
+ db.run("DELETE FROM otium_peer_sessions WHERE host_node_id = ?", [hubNodeId]);
27306
+ db.run("DELETE FROM otium_shared_message_outbox");
27307
+ db.run("DELETE FROM otium_shared_topic_state");
27308
+ db.run(`INSERT INTO otium_peer_lifecycle (hub_node_id, status, updated_at)
27309
+ VALUES (?, 'detached', ?)
27310
+ ON CONFLICT(hub_node_id) DO UPDATE SET status = 'detached', updated_at = excluded.updated_at`, [hubNodeId, new Date().toISOString()]);
27311
+ return topics;
27312
+ })();
27313
+ }
27391
27314
  function listPeerSessions() {
27392
27315
  return db.query("SELECT * FROM otium_peer_sessions").all();
27393
27316
  }
@@ -27581,7 +27504,7 @@ var init_store2 = __esm(async () => {
27581
27504
  db.exec("ALTER TABLE otium_peer_sessions ADD COLUMN binding_mode TEXT NOT NULL DEFAULT 'mirror'");
27582
27505
  }
27583
27506
  db.run(`UPDATE api_topics
27584
- SET visibility = 'hidden', access_mode = 'shared'
27507
+ SET visibility = 'visible', access_mode = 'shared', is_subagent = 0
27585
27508
  WHERE id IN (
27586
27509
  SELECT local_topic_id FROM otium_peer_sessions WHERE binding_mode = 'mirror'
27587
27510
  )`);
@@ -27653,6 +27576,30 @@ var init_store2 = __esm(async () => {
27653
27576
  updated_at INTEGER NOT NULL,
27654
27577
  PRIMARY KEY (node_cell_id, request_id)
27655
27578
  )
27579
+ `);
27580
+ db.exec(`
27581
+ CREATE TABLE IF NOT EXISTS otium_shared_topic_state (
27582
+ local_topic_id TEXT PRIMARY KEY,
27583
+ host_topic_id TEXT,
27584
+ status TEXT NOT NULL CHECK (status IN ('publishing', 'published', 'unpublishing')),
27585
+ updated_at TEXT NOT NULL
27586
+ )
27587
+ `);
27588
+ db.exec(`
27589
+ CREATE TABLE IF NOT EXISTS otium_shared_message_outbox (
27590
+ local_topic_id TEXT NOT NULL,
27591
+ source_message_id TEXT NOT NULL,
27592
+ message_json TEXT NOT NULL,
27593
+ created_at TEXT NOT NULL,
27594
+ PRIMARY KEY (local_topic_id, source_message_id)
27595
+ )
27596
+ `);
27597
+ db.exec(`
27598
+ CREATE TABLE IF NOT EXISTS otium_peer_lifecycle (
27599
+ hub_node_id TEXT PRIMARY KEY,
27600
+ status TEXT NOT NULL CHECK (status IN ('attached', 'detached')),
27601
+ updated_at TEXT NOT NULL
27602
+ )
27656
27603
  `);
27657
27604
  });
27658
27605
 
@@ -27844,7 +27791,7 @@ function createTurnForwarder(opts) {
27844
27791
  logger.warn({ requestId, seq, type: event.type, attempt, error: result.error }, "otium: peer event delivery to hub failed");
27845
27792
  if (attempt < PEER_EVENT_MAX_ATTEMPTS) {
27846
27793
  const delayMs = retryBaseMs * 2 ** (attempt - 1);
27847
- await new Promise((resolve15) => setTimeout(resolve15, delayMs));
27794
+ await new Promise((resolve14) => setTimeout(resolve14, delayMs));
27848
27795
  }
27849
27796
  }
27850
27797
  forwarder.deliveryBlocked = true;
@@ -27983,8 +27930,8 @@ var init_event_backflow = __esm(async () => {
27983
27930
  });
27984
27931
 
27985
27932
  // ../../adapters/otium/src/peer-files.ts
27986
- import { randomUUID as randomUUID26 } from "crypto";
27987
- import { copyFileSync as copyFileSync3, mkdirSync as mkdirSync30, rmSync as rmSync6, statSync as statSync12 } from "fs";
27933
+ import { randomUUID as randomUUID25 } from "crypto";
27934
+ import { copyFileSync as copyFileSync3, mkdirSync as mkdirSync29, rmSync as rmSync6, statSync as statSync12 } from "fs";
27988
27935
  import { basename as basename8, extname as extname5, join as join31 } from "path";
27989
27936
  function fileType(mimeType) {
27990
27937
  if (mimeType.startsWith("image/"))
@@ -28053,8 +28000,8 @@ function recordFile(args) {
28053
28000
  });
28054
28001
  }
28055
28002
  function insertLocalFile(args) {
28056
- const id = randomUUID26();
28057
- mkdirSync30(PEER_FILES_DIR, { recursive: true });
28003
+ const id = randomUUID25();
28004
+ mkdirSync29(PEER_FILES_DIR, { recursive: true });
28058
28005
  const path = join31(PEER_FILES_DIR, `${id}-${safeFilename(args.filename)}`);
28059
28006
  copyFileSync3(args.sourcePath, path);
28060
28007
  return recordFile({ id, path, sizeBytes: statSync12(path).size, ...args });
@@ -28066,8 +28013,8 @@ function deletePeerFilesForTopic(topicId) {
28066
28013
  rmSync6(row.path, { force: true });
28067
28014
  }
28068
28015
  async function storePeerInputFile(file, access) {
28069
- const id = randomUUID26();
28070
- mkdirSync30(PEER_FILES_DIR, { recursive: true });
28016
+ const id = randomUUID25();
28017
+ mkdirSync29(PEER_FILES_DIR, { recursive: true });
28071
28018
  const filename = file.name || "upload";
28072
28019
  const path = join31(PEER_FILES_DIR, `${id}-${safeFilename(filename)}`);
28073
28020
  const sizeBytes = await Bun.write(path, file);
@@ -28132,7 +28079,7 @@ var init_peer_files = __esm(async () => {
28132
28079
  });
28133
28080
 
28134
28081
  // ../../adapters/otium/src/runtime-bridge.ts
28135
- import { randomUUID as randomUUID27 } from "crypto";
28082
+ import { randomUUID as randomUUID26 } from "crypto";
28136
28083
  function isMcpToolResult(value) {
28137
28084
  if (!value || typeof value !== "object" || Array.isArray(value))
28138
28085
  return false;
@@ -28202,7 +28149,7 @@ var init_runtime_bridge = __esm(async () => {
28202
28149
  } catch (error) {
28203
28150
  return errorResult(`Error: Failed to open hub question: ${error.message}`);
28204
28151
  }
28205
- const bridgeRequestId = `bridge-ask-${randomUUID27()}`;
28152
+ const bridgeRequestId = `bridge-ask-${randomUUID26()}`;
28206
28153
  const baseUrl = hubNode.baseUrl.replace(/\/+$/, "");
28207
28154
  const post = async (path, body) => {
28208
28155
  const response = await fetch(`${baseUrl}${path}`, {
@@ -28762,6 +28709,287 @@ var init_session_bridge_ipc = __esm(() => {
28762
28709
  MAX_BODY_BYTES2 = 1024 * 1024;
28763
28710
  });
28764
28711
 
28712
+ // ../../adapters/otium/src/shared-topic-sync.ts
28713
+ var exports_shared_topic_sync = {};
28714
+ __export(exports_shared_topic_sync, {
28715
+ startSharedTopicSync: () => startSharedTopicSync,
28716
+ forwardSharedTopicMessage: () => forwardSharedTopicMessage,
28717
+ downgradeSharedTopicsForHub: () => downgradeSharedTopicsForHub,
28718
+ disconnectSharedTopics: () => disconnectSharedTopics,
28719
+ checkPeerAttachment: () => checkPeerAttachment,
28720
+ acceptSharedTopicMessages: () => acceptSharedTopicMessages
28721
+ });
28722
+ function isMirrorTopic(topicId) {
28723
+ return listPeerSessions().some((row) => row.local_topic_id === topicId && row.binding_mode === "mirror");
28724
+ }
28725
+ function metadata(topic) {
28726
+ if (!topic.agent)
28727
+ throw new Error(`topic ${topic.id} has no executable agent`);
28728
+ const registry = getRegistry(topic.agent);
28729
+ return {
28730
+ localTopicId: topic.id,
28731
+ title: topic.title,
28732
+ ...topic.description ? { description: topic.description } : {},
28733
+ agent: topic.agent,
28734
+ model: topic.defaultModel || registry.defaultModel,
28735
+ effort: topic.defaultEffort ?? "medium"
28736
+ };
28737
+ }
28738
+ async function peerRequest(node, path, init) {
28739
+ const token = await mintPeerToken(node.cellId);
28740
+ const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}${path}`, {
28741
+ ...init,
28742
+ headers: {
28743
+ authorization: `Bearer ${token}`,
28744
+ "content-type": "application/json",
28745
+ ...init.headers ?? {}
28746
+ },
28747
+ signal: AbortSignal.timeout(15000)
28748
+ });
28749
+ const body = await response.json().catch(() => null);
28750
+ if (!response.ok || !body?.ok)
28751
+ throw new Error(String(body?.error ?? `peer request failed (${response.status})`));
28752
+ return body;
28753
+ }
28754
+ function messageEnvelope(message) {
28755
+ const author = message.agentType ? "ai" : message.kind === "system" || message.kind === "tool" ? "system" : "user";
28756
+ return {
28757
+ sourceMessageId: message.sourceMessageId ?? message.id,
28758
+ author,
28759
+ text: message.text,
28760
+ createdAt: message.createdAt,
28761
+ ...message.agentType ? { agent: message.agentType } : {},
28762
+ ...message.model ? { model: message.model } : {},
28763
+ ...message.kind ? { kind: message.kind } : {}
28764
+ };
28765
+ }
28766
+ async function publishTopic(join32, topicId, node) {
28767
+ const topic = getTopic(topicId);
28768
+ if (!topic || topic.kind !== "agent" || !topic.agent || !isTopicShared(topic) || !topic.visibility || topic.visibility === "hidden" || isMirrorTopic(topicId))
28769
+ return;
28770
+ const state = getSharedTopicState(topicId);
28771
+ setSharedTopicState({
28772
+ localTopicId: topicId,
28773
+ hostTopicId: state?.host_topic_id,
28774
+ status: "publishing"
28775
+ });
28776
+ try {
28777
+ const body = await peerRequest(node, "/api/v1/peer/shared-topic", {
28778
+ method: "POST",
28779
+ body: JSON.stringify({
28780
+ v: PEER_PROTOCOL_VERSION,
28781
+ ...metadata(topic)
28782
+ })
28783
+ });
28784
+ const hostTopicId = typeof body.hostTopicId === "string" ? body.hostTopicId : state?.host_topic_id;
28785
+ if (!hostTopicId)
28786
+ throw new Error("shared-topic response omitted hostTopicId");
28787
+ setSharedTopicState({ localTopicId: topicId, hostTopicId, status: "published" });
28788
+ const messages = listApiMessages(topicId, { limit: 200 }).page.filter((message) => !message.sourceNode).map(messageEnvelope);
28789
+ if (messages.length) {
28790
+ await peerRequest(node, "/api/v1/peer/shared-topic/messages", {
28791
+ method: "POST",
28792
+ body: JSON.stringify({
28793
+ v: PEER_PROTOCOL_VERSION,
28794
+ localTopicId: topicId,
28795
+ hostTopicId,
28796
+ messages
28797
+ })
28798
+ });
28799
+ }
28800
+ for (const row of listSharedMessages(topicId)) {
28801
+ await peerRequest(node, "/api/v1/peer/shared-topic/messages", {
28802
+ method: "POST",
28803
+ body: JSON.stringify({
28804
+ v: PEER_PROTOCOL_VERSION,
28805
+ localTopicId: topicId,
28806
+ hostTopicId,
28807
+ messages: [JSON.parse(row.message_json)]
28808
+ })
28809
+ });
28810
+ deleteSharedMessage(topicId, row.source_message_id);
28811
+ }
28812
+ } catch (error) {
28813
+ logger.warn({ error, topicId }, "otium: shared topic publish deferred");
28814
+ setTimeout(() => void reconcileTopic(join32, topicId), RETRY_MS).unref?.();
28815
+ }
28816
+ }
28817
+ async function unpublishTopic(join32, topicId, node) {
28818
+ const state = getSharedTopicState(topicId);
28819
+ if (!state)
28820
+ return;
28821
+ setSharedTopicState({
28822
+ localTopicId: topicId,
28823
+ hostTopicId: state.host_topic_id,
28824
+ status: "unpublishing"
28825
+ });
28826
+ try {
28827
+ await peerRequest(node, `/api/v1/peer/shared-topic/${encodeURIComponent(topicId)}`, {
28828
+ method: "DELETE"
28829
+ });
28830
+ deleteSharedTopicState(topicId);
28831
+ } catch (error) {
28832
+ logger.warn({ error, topicId }, "otium: shared topic unpublish deferred");
28833
+ setTimeout(() => void reconcileTopic(join32, topicId), RETRY_MS).unref?.();
28834
+ }
28835
+ }
28836
+ async function reconcileTopic(join32, topicId, explicit = false) {
28837
+ if (!otiumCentralConfig())
28838
+ return;
28839
+ if (!explicit && isPeerDetached(join32.cellId))
28840
+ return;
28841
+ const target = (await listPeerNodes()).find((node) => node.isPrimary) ?? null;
28842
+ if (!target)
28843
+ return;
28844
+ const topic = getTopic(topicId);
28845
+ if (topic?.kind === "agent" && topic.agent && isTopicShared(topic) && topic.visibility !== "hidden" && !isMirrorTopic(topicId))
28846
+ return publishTopic(join32, topicId, target);
28847
+ return unpublishTopic(join32, topicId, target);
28848
+ }
28849
+ function disconnectSharedTopics(join32) {
28850
+ const states = listSharedTopicStates();
28851
+ const hubPromise = listPeerNodes({ fresh: true }).then((nodes) => nodes.find((node) => node.isPrimary) ?? null, () => null);
28852
+ const updated = downgradeSharedTopicsLocally(join32.cellId);
28853
+ for (const topicId of updated)
28854
+ WsHub.get().broadcastTopicUpdated(topicId);
28855
+ const deletions = states.map(async (state) => {
28856
+ const hub = await hubPromise;
28857
+ if (!hub)
28858
+ return;
28859
+ try {
28860
+ await peerRequest(hub, `/api/v1/peer/shared-topic/${encodeURIComponent(state.local_topic_id)}`, {
28861
+ method: "DELETE"
28862
+ });
28863
+ } catch {}
28864
+ });
28865
+ return Promise.all(deletions).then(() => {
28866
+ return;
28867
+ });
28868
+ }
28869
+ function downgradeSharedTopicsForHub(hubNodeId) {
28870
+ const updated = downgradeSharedTopicsLocally(hubNodeId);
28871
+ for (const topicId of updated)
28872
+ WsHub.get().broadcastTopicUpdated(topicId);
28873
+ return updated.length;
28874
+ }
28875
+ async function checkPeerAttachment(join32) {
28876
+ const nodes = await listPeerNodes({ fresh: true });
28877
+ if (nodes.some((node) => node.cellId === join32.cellId))
28878
+ return true;
28879
+ downgradeSharedTopicsForHub(join32.cellId);
28880
+ return false;
28881
+ }
28882
+ function startSharedTopicSync(join32) {
28883
+ let stopped = false;
28884
+ let detached = isPeerDetached(join32.cellId);
28885
+ const queue = new Set;
28886
+ const schedule = (topicId, explicit = false) => {
28887
+ if (stopped || queue.has(topicId))
28888
+ return;
28889
+ queue.add(topicId);
28890
+ queueMicrotask(async () => {
28891
+ queue.delete(topicId);
28892
+ try {
28893
+ await reconcileTopic(join32, topicId, explicit);
28894
+ } catch (error) {
28895
+ logger.warn({ error, topicId }, "otium: shared topic reconciliation failed");
28896
+ }
28897
+ });
28898
+ };
28899
+ for (const topic of listTopics())
28900
+ schedule(topic.id, false);
28901
+ for (const state of listSharedTopicStates())
28902
+ schedule(state.local_topic_id);
28903
+ const attachmentCheck = setInterval(async () => {
28904
+ if (stopped || detached)
28905
+ return;
28906
+ try {
28907
+ if (!await checkPeerAttachment(join32))
28908
+ detached = true;
28909
+ } catch {}
28910
+ }, 30000);
28911
+ attachmentCheck.unref?.();
28912
+ const unsubscribe2 = runtimeBus().subscribe((event) => {
28913
+ if (event.type === "topic-created" || event.type === "topic-updated" || event.type === "topic-deleted")
28914
+ schedule(event.topicId, true);
28915
+ if (event.type === "message") {
28916
+ const message = event.payload;
28917
+ if (!message.sourceNode) {
28918
+ schedule(event.topicId);
28919
+ forwardSharedTopicMessage(join32, message).catch((error) => logger.warn({ error, topicId: event.topicId }, "otium: shared message deferred"));
28920
+ }
28921
+ }
28922
+ });
28923
+ return () => {
28924
+ stopped = true;
28925
+ clearInterval(attachmentCheck);
28926
+ unsubscribe2();
28927
+ };
28928
+ }
28929
+ async function forwardSharedTopicMessage(_join, message) {
28930
+ if (message.sourceNode || isMirrorTopic(message.topicId))
28931
+ return;
28932
+ if (getActiveForwarder(message.topicId))
28933
+ return;
28934
+ const state = getSharedTopicState(message.topicId);
28935
+ if (!state?.host_topic_id || state.status !== "published")
28936
+ return;
28937
+ const hub = (await listPeerNodes()).find((node) => node.isPrimary) ?? null;
28938
+ if (!hub)
28939
+ return;
28940
+ const envelope = messageEnvelope(message);
28941
+ try {
28942
+ await peerRequest(hub, "/api/v1/peer/shared-topic/messages", {
28943
+ method: "POST",
28944
+ body: JSON.stringify({
28945
+ v: PEER_PROTOCOL_VERSION,
28946
+ localTopicId: message.topicId,
28947
+ hostTopicId: state.host_topic_id,
28948
+ messages: [envelope]
28949
+ })
28950
+ });
28951
+ } catch (error) {
28952
+ enqueueSharedMessage({
28953
+ localTopicId: message.topicId,
28954
+ sourceMessageId: envelope.sourceMessageId,
28955
+ message: envelope
28956
+ });
28957
+ throw error;
28958
+ }
28959
+ }
28960
+ function acceptSharedTopicMessages(messages, localTopicId, sourceNode) {
28961
+ let inserted = 0;
28962
+ for (const incoming of messages) {
28963
+ if (!incoming.sourceMessageId || typeof incoming.text !== "string")
28964
+ continue;
28965
+ if (getApiMessage(localTopicId, incoming.sourceMessageId))
28966
+ continue;
28967
+ const message = {
28968
+ id: incoming.sourceMessageId,
28969
+ topicId: localTopicId,
28970
+ authorId: sourceNode,
28971
+ text: incoming.text,
28972
+ createdAt: incoming.createdAt,
28973
+ ...incoming.agent ? { agentType: incoming.agent } : {},
28974
+ ...incoming.model ? { model: incoming.model } : {},
28975
+ ...incoming.kind ? { kind: incoming.kind } : {},
28976
+ sourceNode,
28977
+ sourceMessageId: incoming.sourceMessageId
28978
+ };
28979
+ appendApiMessage(message);
28980
+ inserted++;
28981
+ }
28982
+ return inserted;
28983
+ }
28984
+ var RETRY_MS = 1000;
28985
+ var init_shared_topic_sync = __esm(async () => {
28986
+ await init_src();
28987
+ await init_central();
28988
+ await init_event_backflow();
28989
+ init_protocol();
28990
+ await init_store2();
28991
+ });
28992
+
28765
28993
  // ../../adapters/otium/src/relay-protocol.ts
28766
28994
  function encodeFrame(frame) {
28767
28995
  const encoded = JSON.stringify(frame);
@@ -29158,6 +29386,8 @@ class TunnelClient {
29158
29386
  const pending2 = this.pendingHttp.get(frame.id);
29159
29387
  try {
29160
29388
  pending2?.bodyController?.enqueue(fromB64(frame.dataB64));
29389
+ if (pending2)
29390
+ this.startPendingHttp(frame.id, pending2);
29161
29391
  } catch {}
29162
29392
  break;
29163
29393
  }
@@ -29165,6 +29395,8 @@ class TunnelClient {
29165
29395
  const pending2 = this.pendingHttp.get(frame.id);
29166
29396
  try {
29167
29397
  pending2?.bodyController?.close();
29398
+ if (pending2)
29399
+ this.startPendingHttp(frame.id, pending2);
29168
29400
  } catch {}
29169
29401
  break;
29170
29402
  }
@@ -29207,21 +29439,35 @@ class TunnelClient {
29207
29439
  }
29208
29440
  startHttpRequest(frame) {
29209
29441
  const abort = new AbortController;
29210
- const pending2 = { abort, bodyController: null };
29211
- this.pendingHttp.set(frame.id, pending2);
29212
- let body;
29442
+ const headers = new Headers;
29443
+ for (const [name, value] of frame.headers)
29444
+ headers.append(name, value);
29445
+ const pending2 = {
29446
+ abort,
29447
+ bodyController: null,
29448
+ body: undefined,
29449
+ method: frame.method,
29450
+ path: frame.path,
29451
+ headers,
29452
+ started: false
29453
+ };
29213
29454
  if (frame.hasBody) {
29214
- body = new ReadableStream({
29455
+ pending2.body = new ReadableStream({
29215
29456
  start: (controller) => {
29216
29457
  pending2.bodyController = controller;
29217
29458
  }
29218
29459
  });
29219
29460
  }
29220
- const headers = new Headers;
29221
- for (const [name, value] of frame.headers)
29222
- headers.append(name, value);
29223
- this.replayHttp(frame.id, frame.method, frame.path, headers, body, abort).finally(() => {
29224
- this.pendingHttp.delete(frame.id);
29461
+ this.pendingHttp.set(frame.id, pending2);
29462
+ if (!frame.hasBody)
29463
+ this.startPendingHttp(frame.id, pending2);
29464
+ }
29465
+ startPendingHttp(id, pending2) {
29466
+ if (pending2.started || pending2.abort.signal.aborted)
29467
+ return;
29468
+ pending2.started = true;
29469
+ this.replayHttp(id, pending2.method, pending2.path, pending2.headers, pending2.body, pending2.abort).finally(() => {
29470
+ this.pendingHttp.delete(id);
29225
29471
  });
29226
29472
  }
29227
29473
  async replayHttp(id, method, path, headers, body, abort) {
@@ -29365,57 +29611,301 @@ function bindOtiumTopic(options) {
29365
29611
  if (!topic.participants.some((participant) => participant.userId === options.userId)) {
29366
29612
  return { ok: false, error: "local topic is not visible to this user", status: 403 };
29367
29613
  }
29368
- if (!isTopicShared(topic)) {
29369
- return { ok: false, error: "private topics must be shared explicitly first", status: 409 };
29614
+ if (!isTopicShared(topic)) {
29615
+ return { ok: false, error: "private topics must be shared explicitly first", status: 409 };
29616
+ }
29617
+ const previous = getPeerSession(options.hostNodeId, options.hostTopicId);
29618
+ bindPeerSession(options.hostNodeId, options.hostTopicId, options.localTopicId, "shared");
29619
+ return {
29620
+ ok: true,
29621
+ localTopicId: options.localTopicId,
29622
+ replaced: Boolean(previous && previous.local_topic_id !== options.localTopicId)
29623
+ };
29624
+ }
29625
+ function shareOtiumTopic(options) {
29626
+ const owned = ownsTopic(options.localTopicId, options.userId);
29627
+ if (!owned.ok)
29628
+ return owned;
29629
+ if (!isTopicShared(owned.topic)) {
29630
+ upsertTopic({ ...owned.topic, accessMode: "shared" });
29631
+ WsHub.get().broadcastTopicUpdated(owned.topic.id);
29632
+ }
29633
+ return bindOtiumTopic(options);
29634
+ }
29635
+ function setOtiumTopicPrivate(options) {
29636
+ const owned = ownsTopic(options.localTopicId, options.userId);
29637
+ if (!owned.ok)
29638
+ return owned;
29639
+ const removedBindings = unbindSharedPeerSessionsForLocalTopic(options.localTopicId);
29640
+ if (isTopicShared(owned.topic)) {
29641
+ upsertTopic({ ...owned.topic, accessMode: "private" });
29642
+ WsHub.get().broadcastTopicUpdated(owned.topic.id);
29643
+ }
29644
+ return { ok: true, localTopicId: options.localTopicId, removedBindings };
29645
+ }
29646
+ function unbindOtiumTopic(hostNodeId, hostTopicId) {
29647
+ return unbindPeerSession(hostNodeId, hostTopicId);
29648
+ }
29649
+ function listOtiumTopicBindings() {
29650
+ return listPeerSessions().map((row) => {
29651
+ const topic = getTopic(row.local_topic_id);
29652
+ return {
29653
+ hostNodeId: row.host_node_id,
29654
+ hostTopicId: row.host_topic_id,
29655
+ localTopicId: row.local_topic_id,
29656
+ transport: row.binding_mode === "shared" ? "shared-binding" : "internal-mirror",
29657
+ ...topic ? { topicAccessMode: topic.accessMode ?? "private" } : {},
29658
+ ...topic ? { localTopicTitle: topic.title } : {},
29659
+ localTopicExists: Boolean(topic),
29660
+ createdAt: row.created_at
29661
+ };
29662
+ });
29663
+ }
29664
+ var init_bindings = __esm(async () => {
29665
+ await init_src();
29666
+ await init_store2();
29667
+ });
29668
+
29669
+ // ../../adapters/otium/src/enrollment.ts
29670
+ import {
29671
+ createDecipheriv as createDecipheriv2,
29672
+ createPrivateKey,
29673
+ createPublicKey,
29674
+ diffieHellman,
29675
+ generateKeyPairSync,
29676
+ hkdfSync,
29677
+ randomUUID as randomUUID27
29678
+ } from "crypto";
29679
+ import {
29680
+ chmodSync as chmodSync6,
29681
+ closeSync as closeSync4,
29682
+ existsSync as existsSync26,
29683
+ fsyncSync as fsyncSync3,
29684
+ linkSync as linkSync2,
29685
+ mkdirSync as mkdirSync30,
29686
+ openSync as openSync4,
29687
+ readFileSync as readFileSync21,
29688
+ renameSync as renameSync14,
29689
+ unlinkSync as unlinkSync19,
29690
+ writeFileSync as writeFileSync19
29691
+ } from "fs";
29692
+ import { dirname as dirname18, resolve as resolve14 } from "path";
29693
+ function parseEnrollmentInvite(code) {
29694
+ let parsed;
29695
+ try {
29696
+ parsed = JSON.parse(Buffer.from(code.trim(), "base64url").toString("utf8"));
29697
+ } catch {
29698
+ throw new Error("production invite must be base64url JSON");
29699
+ }
29700
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
29701
+ throw new Error("production invite must decode to an object");
29702
+ }
29703
+ const raw = parsed;
29704
+ const central = typeof raw.central === "string" ? raw.central.trim().replace(/\/+$/, "") : "";
29705
+ const token = typeof raw.token === "string" ? raw.token.trim() : "";
29706
+ if (raw.v !== 2 || !central || !token.startsWith("nei_")) {
29707
+ throw new Error("production invite requires {v:2, central:http(s), token:nei_\u2026}");
29708
+ }
29709
+ assertSecureCentralUrl(central);
29710
+ return { v: 2, central, token };
29711
+ }
29712
+ function pendingEnrollmentPath() {
29713
+ return resolve14(DATA_DIR, "otium-enrollment-pending.json");
29714
+ }
29715
+ function isEnrollmentPending(invite) {
29716
+ const path = pendingEnrollmentPath();
29717
+ if (!existsSync26(path))
29718
+ return false;
29719
+ try {
29720
+ const saved = JSON.parse(readFileSync21(path, "utf8"));
29721
+ return saved.central === invite.central && saved.token === invite.token;
29722
+ } catch {
29723
+ return false;
29724
+ }
29725
+ }
29726
+ function loadOrCreatePending(invite, nodeName) {
29727
+ const path = pendingEnrollmentPath();
29728
+ if (existsSync26(path)) {
29729
+ const saved = JSON.parse(readFileSync21(path, "utf8"));
29730
+ if (saved.central === invite.central && saved.token === invite.token)
29731
+ return saved;
29732
+ throw new Error(`another Otium enrollment is pending at ${path}`);
29370
29733
  }
29371
- const previous = getPeerSession(options.hostNodeId, options.hostTopicId);
29372
- bindPeerSession(options.hostNodeId, options.hostTopicId, options.localTopicId, "shared");
29373
- return {
29374
- ok: true,
29375
- localTopicId: options.localTopicId,
29376
- replaced: Boolean(previous && previous.local_topic_id !== options.localTopicId)
29734
+ const pair = generateKeyPairSync("x25519");
29735
+ const pending2 = {
29736
+ ...invite,
29737
+ idempotencyKey: randomUUID27(),
29738
+ privateKey: pair.privateKey.export({ format: "der", type: "pkcs8" }).toString("base64url"),
29739
+ publicKey: pair.publicKey.export({ format: "der", type: "spki" }).toString("base64url"),
29740
+ ...nodeName ? { nodeName } : {}
29377
29741
  };
29742
+ mkdirSync30(dirname18(path), { recursive: true });
29743
+ const temporaryPath = resolve14(dirname18(path), `.otium-enrollment-pending.json.${process.pid}.${randomUUID27()}.tmp`);
29744
+ let fd;
29745
+ try {
29746
+ fd = openSync4(temporaryPath, "wx", 384);
29747
+ writeFileSync19(fd, `${JSON.stringify(pending2, null, 2)}
29748
+ `, "utf8");
29749
+ fsyncSync3(fd);
29750
+ closeSync4(fd);
29751
+ fd = undefined;
29752
+ linkSync2(temporaryPath, path);
29753
+ unlinkSync19(temporaryPath);
29754
+ chmodSync6(path, 384);
29755
+ const directoryFd = openSync4(dirname18(path), "r");
29756
+ try {
29757
+ fsyncSync3(directoryFd);
29758
+ } finally {
29759
+ closeSync4(directoryFd);
29760
+ }
29761
+ } catch (error) {
29762
+ if (fd !== undefined)
29763
+ closeSync4(fd);
29764
+ if (existsSync26(temporaryPath))
29765
+ unlinkSync19(temporaryPath);
29766
+ if (error.code === "EEXIST" && existsSync26(path)) {
29767
+ const saved = JSON.parse(readFileSync21(path, "utf8"));
29768
+ if (saved.central === invite.central && saved.token === invite.token)
29769
+ return saved;
29770
+ }
29771
+ throw error;
29772
+ }
29773
+ return pending2;
29378
29774
  }
29379
- function shareOtiumTopic(options) {
29380
- const owned = ownsTopic(options.localTopicId, options.userId);
29381
- if (!owned.ok)
29382
- return owned;
29383
- if (!isTopicShared(owned.topic)) {
29384
- upsertTopic({ ...owned.topic, accessMode: "shared" });
29775
+ function replacePendingEnrollment(pending2) {
29776
+ const path = pendingEnrollmentPath();
29777
+ const directory = dirname18(path);
29778
+ const temporaryPath = resolve14(directory, `.otium-enrollment-pending.json.${process.pid}.${randomUUID27()}.tmp`);
29779
+ let fd;
29780
+ try {
29781
+ fd = openSync4(temporaryPath, "wx", 384);
29782
+ writeFileSync19(fd, `${JSON.stringify(pending2, null, 2)}
29783
+ `, "utf8");
29784
+ fsyncSync3(fd);
29785
+ closeSync4(fd);
29786
+ fd = undefined;
29787
+ renameSync14(temporaryPath, path);
29788
+ const directoryFd = openSync4(directory, "r");
29789
+ try {
29790
+ fsyncSync3(directoryFd);
29791
+ } finally {
29792
+ closeSync4(directoryFd);
29793
+ }
29794
+ } catch (error) {
29795
+ if (fd !== undefined)
29796
+ closeSync4(fd);
29797
+ if (existsSync26(temporaryPath))
29798
+ unlinkSync19(temporaryPath);
29799
+ throw error;
29385
29800
  }
29386
- return bindOtiumTopic(options);
29387
29801
  }
29388
- function setOtiumTopicPrivate(options) {
29389
- const owned = ownsTopic(options.localTopicId, options.userId);
29390
- if (!owned.ok)
29391
- return owned;
29392
- const removedBindings = unbindSharedPeerSessionsForLocalTopic(options.localTopicId);
29393
- if (isTopicShared(owned.topic)) {
29394
- upsertTopic({ ...owned.topic, accessMode: "private" });
29802
+ function recordClaimedCredential(pending2, join32) {
29803
+ withJoinCredentialLock(() => {
29804
+ const current3 = JSON.parse(readFileSync21(pendingEnrollmentPath(), "utf8"));
29805
+ if (current3.central !== pending2.central || current3.token !== pending2.token || current3.idempotencyKey !== pending2.idempotencyKey || current3.publicKey !== pending2.publicKey) {
29806
+ throw new Error("pending Otium enrollment changed while its claim was in flight");
29807
+ }
29808
+ const claimed = { digest: joinCredentialDigest(join32), cellId: join32.cellId };
29809
+ if (current3.claimed && (current3.claimed.digest !== claimed.digest || current3.claimed.cellId !== claimed.cellId)) {
29810
+ throw new Error("central returned different credentials for an idempotent enrollment claim");
29811
+ }
29812
+ replacePendingEnrollment({ ...current3, claimed });
29813
+ });
29814
+ }
29815
+ function openCredential(envelope, privateKeyDer) {
29816
+ if (envelope.v !== 1 || envelope.algorithm !== "X25519-HKDF-SHA256-AES-256-GCM") {
29817
+ throw new Error("unsupported enrollment credential envelope");
29395
29818
  }
29396
- return { ok: true, localTopicId: options.localTopicId, removedBindings };
29819
+ const privateKey = createPrivateKey({
29820
+ key: Buffer.from(privateKeyDer, "base64url"),
29821
+ format: "der",
29822
+ type: "pkcs8"
29823
+ });
29824
+ const ephemeral = createPublicKey({
29825
+ key: Buffer.from(envelope.ephemeralPublicKey, "base64url"),
29826
+ format: "der",
29827
+ type: "spki"
29828
+ });
29829
+ const shared = diffieHellman({ privateKey, publicKey: ephemeral });
29830
+ const key = Buffer.from(hkdfSync("sha256", shared, Buffer.from(envelope.salt, "base64url"), INFO, 32));
29831
+ const decipher = createDecipheriv2("aes-256-gcm", key, Buffer.from(envelope.nonce, "base64url"));
29832
+ decipher.setAAD(INFO);
29833
+ decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
29834
+ return Buffer.concat([
29835
+ decipher.update(Buffer.from(envelope.ciphertext, "base64url")),
29836
+ decipher.final()
29837
+ ]).toString("utf8");
29397
29838
  }
29398
- function unbindOtiumTopic(hostNodeId, hostTopicId) {
29399
- return unbindPeerSession(hostNodeId, hostTopicId);
29839
+ async function postJson(url, body, headers) {
29840
+ const response = await fetch(url, {
29841
+ method: "POST",
29842
+ headers: { "content-type": "application/json", ...headers },
29843
+ body: JSON.stringify(body)
29844
+ });
29845
+ const result = await response.json();
29846
+ if (!response.ok)
29847
+ throw new Error(String(result.error || `request failed (${response.status})`));
29848
+ return result;
29400
29849
  }
29401
- function listOtiumTopicBindings() {
29402
- return listPeerSessions().map((row) => {
29403
- const topic = getTopic(row.local_topic_id);
29404
- return {
29405
- hostNodeId: row.host_node_id,
29406
- hostTopicId: row.host_topic_id,
29407
- localTopicId: row.local_topic_id,
29408
- transport: row.binding_mode === "shared" ? "shared-binding" : "internal-mirror",
29409
- ...topic ? { topicAccessMode: topic.accessMode ?? "private" } : {},
29410
- ...topic ? { localTopicTitle: topic.title } : {},
29411
- localTopicExists: Boolean(topic),
29412
- createdAt: row.created_at
29413
- };
29850
+ async function previewEnrollment(invite) {
29851
+ return postJson(`${invite.central}/api/v1/node-enrollments/preview`, {
29852
+ token: invite.token
29414
29853
  });
29415
29854
  }
29416
- var init_bindings = __esm(async () => {
29855
+ async function claimEnrollment(invite, nodeName) {
29856
+ const pending2 = loadOrCreatePending(invite, nodeName);
29857
+ const response = await postJson(`${invite.central}/api/v1/node-enrollments/claim`, {
29858
+ token: invite.token,
29859
+ credentialPublicKey: pending2.publicKey,
29860
+ ...pending2.nodeName ? { nodeName: pending2.nodeName } : {}
29861
+ }, { "idempotency-key": pending2.idempotencyKey });
29862
+ const credential = response.credential;
29863
+ const secret = openCredential(credential, pending2.privateKey);
29864
+ if (!secret.startsWith("rcs_"))
29865
+ throw new Error("central returned an invalid runtime credential");
29866
+ const join32 = {
29867
+ v: 2,
29868
+ central: invite.central,
29869
+ relay: String(response.relayUrl),
29870
+ cellId: String(response.cell?.id),
29871
+ secret
29872
+ };
29873
+ if (!join32.relay || !join32.cellId || join32.cellId === "undefined") {
29874
+ throw new Error("central returned an incomplete enrollment response");
29875
+ }
29876
+ assertSecureRelayUrl(join32.relay);
29877
+ recordClaimedCredential(pending2, join32);
29878
+ return join32;
29879
+ }
29880
+ function commitEnrollment(join32, options = {}) {
29881
+ return withJoinCredentialLock(() => {
29882
+ const pendingPath = pendingEnrollmentPath();
29883
+ const pending2 = existsSync26(pendingPath) ? JSON.parse(readFileSync21(pendingPath, "utf8")) : null;
29884
+ const digest = joinCredentialDigest(join32);
29885
+ if (pending2 && (!pending2.claimed || pending2.claimed.digest !== digest || pending2.claimed.cellId !== join32.cellId)) {
29886
+ throw new Error(`pending Otium enrollment at ${pendingPath} does not match these credentials`);
29887
+ }
29888
+ const path = saveJoinWhileLocked(join32, options);
29889
+ if (!isJoinPersisted(join32)) {
29890
+ throw new Error("Otium join credentials were not durably persisted");
29891
+ }
29892
+ if (!pending2)
29893
+ return path;
29894
+ unlinkSync19(pendingPath);
29895
+ const directoryFd = openSync4(dirname18(pendingPath), "r");
29896
+ try {
29897
+ fsyncSync3(directoryFd);
29898
+ } finally {
29899
+ closeSync4(directoryFd);
29900
+ }
29901
+ return path;
29902
+ });
29903
+ }
29904
+ var INFO;
29905
+ var init_enrollment = __esm(async () => {
29417
29906
  await init_src();
29418
- await init_store2();
29907
+ await init_join();
29908
+ INFO = Buffer.from("otium-node-enrollment-v1", "utf8");
29419
29909
  });
29420
29910
 
29421
29911
  // ../../adapters/otium/src/turn-bridge.ts
@@ -29471,8 +29961,8 @@ function provisionMirrorTopic(hostCellId, payload) {
29471
29961
  defaultModel: nextModel,
29472
29962
  defaultEffort: EFFORT_LEVELS.includes(execution.effort) ? execution.effort : current3?.defaultEffort ?? "medium",
29473
29963
  participants: [{ userId: payload.userId, role: "owner" }],
29474
- isSubagent: true,
29475
- visibility: "hidden",
29964
+ isSubagent: undefined,
29965
+ visibility: "visible",
29476
29966
  accessMode: "shared",
29477
29967
  ...execution.description ? { description: execution.description } : {},
29478
29968
  createdAt: current3?.createdAt ?? now,
@@ -29550,6 +30040,8 @@ function runPeerTurn(hubNode, hostCellId, payload, opts = {}) {
29550
30040
  origin: "user",
29551
30041
  requestId: payload.requestId,
29552
30042
  injectAuthorId: payload.userId,
30043
+ injectSourceNode: hostCellId,
30044
+ ...payload.sourceMessageId ? { injectSourceMessageId: payload.sourceMessageId } : {},
29553
30045
  attachments: payload.attachments,
29554
30046
  visualTools: true,
29555
30047
  fileDeliveryTools: true,
@@ -29736,7 +30228,7 @@ async function handleProvision(req) {
29736
30228
  });
29737
30229
  if (!result.ok)
29738
30230
  return jsonError2(result.error, result.status);
29739
- logger.info({ hostTopicId, localTopicId: result.localTopicId, fromNode: peer.verified.fromNodeName }, "otium: hidden mirror room provisioned");
30231
+ logger.info({ hostTopicId, localTopicId: result.localTopicId, fromNode: peer.verified.fromNodeName }, "otium: mirror room provisioned");
29740
30232
  return Response.json({ ok: true });
29741
30233
  }
29742
30234
  async function handleBind(req) {
@@ -29768,6 +30260,50 @@ async function handleBind(req) {
29768
30260
  return jsonError2(result.error, result.status);
29769
30261
  return Response.json({ ok: true, localTopicId: result.localTopicId, replaced: result.replaced });
29770
30262
  }
30263
+ async function handleSharedTopicMessages(req) {
30264
+ const peer = await requirePeer(req);
30265
+ if (!peer.ok)
30266
+ return peer.response;
30267
+ const originError = requirePrimaryOrigin(peer);
30268
+ if (originError)
30269
+ return originError;
30270
+ const body = await readBody(req);
30271
+ if (!body)
30272
+ return jsonError2("invalid JSON body", 400);
30273
+ const protocolError = checkProtocol(body);
30274
+ if (protocolError)
30275
+ return protocolError;
30276
+ const localTopicId = str2(body, "localTopicId");
30277
+ const hostTopicId = str2(body, "hostTopicId");
30278
+ const messages = body.messages;
30279
+ if (!localTopicId || !hostTopicId || !Array.isArray(messages)) {
30280
+ return jsonError2("localTopicId, hostTopicId and messages are required", 400);
30281
+ }
30282
+ const session = getPeerSession(peer.verified.fromCellId, hostTopicId);
30283
+ if (!session || session.local_topic_id !== localTopicId) {
30284
+ return jsonError2("shared topic binding not found", 404);
30285
+ }
30286
+ const accepted = acceptSharedTopicMessages(messages, localTopicId, peer.verified.fromCellId);
30287
+ return Response.json({ ok: true, accepted });
30288
+ }
30289
+ async function handleSharedTopicsPrivate(req) {
30290
+ const peer = await requirePeer(req);
30291
+ if (!peer.ok)
30292
+ return peer.response;
30293
+ const originError = requirePrimaryOrigin(peer);
30294
+ if (originError)
30295
+ return originError;
30296
+ const body = await readBody(req);
30297
+ if (!body)
30298
+ return jsonError2("invalid JSON body", 400);
30299
+ const protocolError = checkProtocol(body);
30300
+ if (protocolError)
30301
+ return protocolError;
30302
+ if (body.reason !== "hub-removal")
30303
+ return jsonError2("reason must be hub-removal", 400);
30304
+ const updated = downgradeSharedTopicsForHub(peer.verified.fromCellId);
30305
+ return Response.json({ ok: true, updated });
30306
+ }
29771
30307
  async function handleUnbind(req) {
29772
30308
  const peer = await requirePeer(req);
29773
30309
  if (!peer.ok)
@@ -29829,6 +30365,7 @@ async function handleTurn(req) {
29829
30365
  ...str2(body, "model") ? { model: str2(body, "model") } : {},
29830
30366
  ...str2(body, "effort") ? { effort: str2(body, "effort") } : {},
29831
30367
  ...Array.isArray(body.attachments) && body.attachments.every((entry) => typeof entry === "string") ? { attachments: body.attachments } : {},
30368
+ ...str2(body, "sourceMessageId") ? { sourceMessageId: str2(body, "sourceMessageId") } : {},
29832
30369
  message
29833
30370
  };
29834
30371
  const result = runPeerTurn(hubNode, peer.verified.fromCellId, payload);
@@ -30182,6 +30719,10 @@ async function handleOtiumPeerRequest(req) {
30182
30719
  return handleProvision(req);
30183
30720
  case "/api/v1/peer/bind":
30184
30721
  return handleBind(req);
30722
+ case "/api/v1/peer/shared-topic/messages":
30723
+ return handleSharedTopicMessages(req);
30724
+ case "/api/v1/peer/shared-topics/private":
30725
+ return handleSharedTopicsPrivate(req);
30185
30726
  case "/api/v1/peer/unbind":
30186
30727
  return handleUnbind(req);
30187
30728
  case "/api/v1/peer/turn":
@@ -30210,62 +30751,16 @@ var init_peer_server = __esm(async () => {
30210
30751
  await init_bindings();
30211
30752
  await init_central();
30212
30753
  await init_peer_files();
30213
- init_protocol();
30214
- await init_session_bridge();
30215
- await init_store2();
30216
- await init_turn_bridge();
30217
- RUNTIME_VERSION = NEGOTIUM_VERSION;
30218
- });
30219
-
30220
- // ../../adapters/otium/src/index.ts
30221
- var exports_src5 = {};
30222
- __export(exports_src5, {
30223
- verifyPeerToken: () => verifyPeerToken,
30224
- unbindOtiumTopic: () => unbindOtiumTopic,
30225
- translateBusEvent: () => translateBusEvent,
30226
- stopEventBackflow: () => stopEventBackflow,
30227
- startOtiumWorker: () => startOtiumWorker,
30228
- startOtiumAdapter: () => startOtiumAdapter,
30229
- startEventBackflow: () => startEventBackflow,
30230
- shareOtiumTopic: () => shareOtiumTopic,
30231
- setOtiumTopicPrivate: () => setOtiumTopicPrivate,
30232
- selfPeerNode: () => selfPeerNode,
30233
- saveJoin: () => saveJoin,
30234
- runPeerTurn: () => runPeerTurn,
30235
- resolvePeerNodeByCellId: () => resolvePeerNodeByCellId,
30236
- resetPeerCentralCaches: () => resetPeerCentralCaches,
30237
- registerTurnForwarder: () => registerTurnForwarder,
30238
- provisionMirrorTopic: () => provisionMirrorTopic,
30239
- previewEnrollment: () => previewEnrollment,
30240
- pendingEnrollmentPath: () => pendingEnrollmentPath,
30241
- parseInviteCode: () => parseInviteCode,
30242
- parseEnrollmentInvite: () => parseEnrollmentInvite,
30243
- otiumPeerRuntimeBridge: () => otiumPeerRuntimeBridge,
30244
- otiumCentralConfig: () => otiumCentralConfig,
30245
- otiumAdapter: () => otiumAdapter,
30246
- mintPeerToken: () => mintPeerToken,
30247
- loadJoin: () => loadJoin,
30248
- listPeerNodes: () => listPeerNodes,
30249
- listOtiumTopicBindings: () => listOtiumTopicBindings,
30250
- joinFilePath: () => joinFilePath,
30251
- isJoinPersisted: () => isJoinPersisted,
30252
- isEnrollmentPending: () => isEnrollmentPending,
30253
- hubEventSender: () => hubEventSender,
30254
- handleOtiumPeerRequest: () => handleOtiumPeerRequest,
30255
- getActiveForwarder: () => getActiveForwarder,
30256
- failInterruptedPeerTurnRequestsOnStartup: () => failInterruptedPeerTurnRequestsOnStartup,
30257
- createTurnForwarder: () => createTurnForwarder,
30258
- configureOtiumCentral: () => configureOtiumCentral,
30259
- commitEnrollment: () => commitEnrollment,
30260
- cleanupPeerStateForLocalTopic: () => cleanupPeerStateForLocalTopic,
30261
- claimEnrollment: () => claimEnrollment,
30262
- bindOtiumTopic: () => bindOtiumTopic,
30263
- abortHostedPeerTurn: () => abortHostedPeerTurn,
30264
- TunnelClient: () => TunnelClient,
30265
- RELAY_PROTOCOL_VERSION: () => PROTOCOL_VERSION,
30266
- PEER_PROTOCOL_VERSION: () => PEER_PROTOCOL_VERSION
30754
+ init_protocol();
30755
+ await init_session_bridge();
30756
+ await init_shared_topic_sync();
30757
+ await init_store2();
30758
+ await init_turn_bridge();
30759
+ RUNTIME_VERSION = NEGOTIUM_VERSION;
30267
30760
  });
30268
- function startOtiumAdapter(options) {
30761
+
30762
+ // ../../adapters/otium/src/index.ts
30763
+ function startOtiumNodeRuntime(options) {
30269
30764
  const { join: join32 } = options;
30270
30765
  configureOtiumCentral(join32);
30271
30766
  const failed = failInterruptedPeerTurnRequestsOnStartup();
@@ -30273,6 +30768,7 @@ function startOtiumAdapter(options) {
30273
30768
  logger.warn({ failed }, "otium: failed interrupted peer turns from previous process");
30274
30769
  }
30275
30770
  const stopBackflow = startEventBackflow();
30771
+ const stopSharedTopicSync = startSharedTopicSync(join32);
30276
30772
  const unregisterRuntimeBridge = registerPeerRuntimeBridge(otiumPeerRuntimeBridge);
30277
30773
  const unregisterSessionBridge = registerPeerSessionBridge(otiumPeerSessionBridge);
30278
30774
  const sessionBridgeIpc = startPeerSessionBridgeIpc(otiumPeerSessionBridge);
@@ -30293,7 +30789,6 @@ function startOtiumAdapter(options) {
30293
30789
  }
30294
30790
  });
30295
30791
  let stopped = false;
30296
- let tunnel = null;
30297
30792
  logger.info({ central: join32.central, cellId: join32.cellId }, "otium: worker mode enabled");
30298
30793
  selfPeerNode().then((self) => {
30299
30794
  if (self) {
@@ -30305,17 +30800,41 @@ function startOtiumAdapter(options) {
30305
30800
  return {
30306
30801
  name: "otium",
30307
30802
  join: join32,
30803
+ stop: () => {
30804
+ if (stopped)
30805
+ return;
30806
+ stopped = true;
30807
+ unsubscribeTopicCleanup();
30808
+ unregisterRuntimeBridge();
30809
+ unregisterSessionBridge();
30810
+ sessionBridgeIpc.stop();
30811
+ canonicalMcpBridge.stop();
30812
+ stopPeerReplyOutbox();
30813
+ uninstallFileHooks();
30814
+ stopBackflow();
30815
+ stopSharedTopicSync();
30816
+ configureOtiumCentral(null);
30817
+ }
30818
+ };
30819
+ }
30820
+ function startOtiumAdapter(options) {
30821
+ const runtime = startOtiumNodeRuntime(options);
30822
+ let tunnel = null;
30823
+ let stopped = false;
30824
+ return {
30825
+ name: "otium",
30826
+ join: runtime.join,
30308
30827
  startTunnel: ({ targetOrigin, relayUrl }) => {
30309
30828
  if (stopped || tunnel)
30310
30829
  return;
30311
- const selectedRelay = relayUrl?.trim() || join32.relay || process.env.OTIUM_RELAY_URL?.trim();
30830
+ const selectedRelay = relayUrl?.trim() || runtime.join.relay || process.env.OTIUM_RELAY_URL?.trim();
30312
30831
  if (!selectedRelay) {
30313
30832
  logger.info({}, "otium: relay tunnel disabled (no relay URL configured)");
30314
30833
  return;
30315
30834
  }
30316
30835
  tunnel = new TunnelClient({
30317
30836
  relayUrl: selectedRelay,
30318
- token: join32.secret,
30837
+ token: runtime.join.secret,
30319
30838
  targetOrigin,
30320
30839
  nodeVersion: `negotium@${NEGOTIUM_VERSION}`,
30321
30840
  logger
@@ -30328,24 +30847,10 @@ function startOtiumAdapter(options) {
30328
30847
  stopped = true;
30329
30848
  tunnel?.stop();
30330
30849
  tunnel = null;
30331
- unsubscribeTopicCleanup();
30332
- unregisterRuntimeBridge();
30333
- unregisterSessionBridge();
30334
- sessionBridgeIpc.stop();
30335
- canonicalMcpBridge.stop();
30336
- stopPeerReplyOutbox();
30337
- uninstallFileHooks();
30338
- stopBackflow();
30339
- configureOtiumCentral(null);
30850
+ runtime.stop();
30340
30851
  }
30341
30852
  };
30342
30853
  }
30343
- function startOtiumWorker() {
30344
- const join32 = loadJoin();
30345
- if (!join32)
30346
- return null;
30347
- return startOtiumAdapter({ join: join32 });
30348
- }
30349
30854
  var otiumAdapter;
30350
30855
  var init_src8 = __esm(async () => {
30351
30856
  await init_src();
@@ -30357,6 +30862,7 @@ var init_src8 = __esm(async () => {
30357
30862
  await init_runtime_bridge();
30358
30863
  await init_session_bridge();
30359
30864
  init_session_bridge_ipc();
30865
+ await init_shared_topic_sync();
30360
30866
  await init_store2();
30361
30867
  init_tunnel_client();
30362
30868
  await init_bindings();
@@ -30368,6 +30874,7 @@ var init_src8 = __esm(async () => {
30368
30874
  init_protocol();
30369
30875
  init_relay_protocol();
30370
30876
  await init_runtime_bridge();
30877
+ await init_shared_topic_sync();
30371
30878
  await init_store2();
30372
30879
  init_tunnel_client();
30373
30880
  await init_turn_bridge();
@@ -30387,6 +30894,245 @@ var init_src8 = __esm(async () => {
30387
30894
  });
30388
30895
  });
30389
30896
 
30897
+ // ../../adapters/otium/src/node-runtime.ts
30898
+ var exports_node_runtime = {};
30899
+ __export(exports_node_runtime, {
30900
+ mountConfiguredOtiumNodeRuntime: () => mountConfiguredOtiumNodeRuntime,
30901
+ handleOtiumAdapterControlRequest: () => handleOtiumAdapterControlRequest,
30902
+ OTIUM_ADAPTER_CONTROL_PREFIX: () => OTIUM_ADAPTER_CONTROL_PREFIX,
30903
+ OTIUM_ADAPTER_CONTROL_HEADER: () => OTIUM_ADAPTER_CONTROL_HEADER,
30904
+ MAX_PEER_INPUT_REQUEST_BYTES: () => MAX_PEER_INPUT_REQUEST_BYTES
30905
+ });
30906
+ async function handleOtiumAdapterControlRequest(req) {
30907
+ const url = new URL(req.url);
30908
+ if (!url.pathname.startsWith(`${OTIUM_ADAPTER_CONTROL_PREFIX}/`))
30909
+ return null;
30910
+ if (req.headers.get(OTIUM_ADAPTER_CONTROL_HEADER) !== NODE_CONTROL_TOKEN) {
30911
+ return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
30912
+ }
30913
+ const peerPath = url.pathname.slice(OTIUM_ADAPTER_CONTROL_PREFIX.length) || "/";
30914
+ const peerUrl = new URL(req.url);
30915
+ peerUrl.pathname = peerPath;
30916
+ const headers = new Headers(req.headers);
30917
+ headers.delete(OTIUM_ADAPTER_CONTROL_HEADER);
30918
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
30919
+ return await handleOtiumPeerRequest(new Request(peerUrl.toString(), { method: req.method, headers, body, signal: req.signal })) ?? Response.json({ ok: false, error: "Otium route not found" }, { status: 404 });
30920
+ }
30921
+ function mountConfiguredOtiumNodeRuntime() {
30922
+ const join32 = loadJoin();
30923
+ if (!join32)
30924
+ return null;
30925
+ const runtime = startOtiumNodeRuntime({ join: join32 });
30926
+ registerNodeRequestHandler("otium-adapter-control", handleOtiumAdapterControlRequest);
30927
+ let stopped = false;
30928
+ return {
30929
+ ...runtime,
30930
+ stop() {
30931
+ if (stopped)
30932
+ return;
30933
+ stopped = true;
30934
+ unregisterNodeRequestHandler("otium-adapter-control");
30935
+ runtime.stop();
30936
+ }
30937
+ };
30938
+ }
30939
+ var init_node_runtime = __esm(async () => {
30940
+ await init_src();
30941
+ await init_src8();
30942
+ await init_join();
30943
+ await init_peer_server();
30944
+ init_protocol();
30945
+ });
30946
+
30947
+ // ../../adapters/otium/src/join-cli.ts
30948
+ var exports_join_cli = {};
30949
+ __export(exports_join_cli, {
30950
+ joinCommand: () => joinCommand
30951
+ });
30952
+ function option2(args, name) {
30953
+ const index = args.indexOf(`--${name}`);
30954
+ return index >= 0 ? args[index + 1]?.trim() : undefined;
30955
+ }
30956
+ async function confirmEnrollment(message) {
30957
+ if (!process.stdin.isTTY)
30958
+ return false;
30959
+ const { createInterface } = await import("readline/promises");
30960
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
30961
+ try {
30962
+ return /^(?:y|yes)$/i.test((await prompt.question(`${message} [y/N] `)).trim());
30963
+ } finally {
30964
+ prompt.close();
30965
+ }
30966
+ }
30967
+ async function joinCommand(args) {
30968
+ const code = args[0]?.trim();
30969
+ if (!code) {
30970
+ console.error("usage: negotium-otium join <invite-code> [--yes] [--name <node-name>] [--legacy] [--replace]");
30971
+ process.exitCode = 1;
30972
+ return;
30973
+ }
30974
+ let join32;
30975
+ let productionEnrollment = false;
30976
+ if (args.includes("--legacy")) {
30977
+ try {
30978
+ join32 = parseInviteCode(code);
30979
+ } catch (err2) {
30980
+ console.error(`invalid legacy invite code: ${err2 instanceof Error ? err2.message : err2}`);
30981
+ process.exitCode = 1;
30982
+ return;
30983
+ }
30984
+ } else {
30985
+ try {
30986
+ const invite = parseEnrollmentInvite(code);
30987
+ const resuming = isEnrollmentPending(invite);
30988
+ let nodeName = option2(args, "name");
30989
+ if (resuming) {
30990
+ console.log(`Resuming interrupted Otium enrollment with ${invite.central}`);
30991
+ } else {
30992
+ const preview = await previewEnrollment(invite);
30993
+ const workspace = preview.preview?.workspace;
30994
+ nodeName ||= preview.preview?.suggestedNodeName || undefined;
30995
+ console.log(`Otium workspace: ${workspace?.name ?? workspace?.slug ?? workspace?.id}`);
30996
+ console.log(` central: ${invite.central}`);
30997
+ console.log(` transport: ${preview.preview?.transport ?? "relay"}`);
30998
+ console.log(` access: ${preview.preview?.topics ?? "explicitly shared topics only"}`);
30999
+ const accepted = args.includes("--yes") || await confirmEnrollment("Join this workspace?");
31000
+ if (!accepted) {
31001
+ console.log("enrollment cancelled");
31002
+ return;
31003
+ }
31004
+ }
31005
+ join32 = await claimEnrollment(invite, nodeName);
31006
+ productionEnrollment = true;
31007
+ } catch (err2) {
31008
+ console.error(`enrollment failed: ${err2 instanceof Error ? err2.message : err2}`);
31009
+ process.exitCode = 1;
31010
+ return;
31011
+ }
31012
+ }
31013
+ let path;
31014
+ try {
31015
+ const saveOptions = { replaceExisting: args.includes("--replace") };
31016
+ path = productionEnrollment ? commitEnrollment(join32, saveOptions) : saveJoin(join32, saveOptions);
31017
+ } catch (err2) {
31018
+ console.error(`could not save join credentials: ${err2 instanceof Error ? err2.message : err2}`);
31019
+ process.exitCode = 1;
31020
+ return;
31021
+ }
31022
+ console.log(`otium join credentials saved to ${path}`);
31023
+ console.log(` central: ${join32.central}`);
31024
+ console.log(` cellId: ${join32.cellId}`);
31025
+ configureOtiumCentral(join32);
31026
+ try {
31027
+ const self = await selfPeerNode();
31028
+ if (self) {
31029
+ console.log(`attached to workspace as "${self.nodeName ?? self.cellId}" (baseUrl ${self.baseUrl})`);
31030
+ } else {
31031
+ console.warn("warning: central answered but this cell has no visible assignment yet \u2014 check the workspace assignment");
31032
+ }
31033
+ } catch (err2) {
31034
+ console.warn(`warning: could not verify against central (${err2 instanceof Error ? err2.message : err2}) \u2014 credentials saved anyway`);
31035
+ } finally {
31036
+ configureOtiumCentral(null);
31037
+ }
31038
+ console.log("\nnext: `negotium-otium serve` (mounts the otium peer routes automatically)");
31039
+ }
31040
+ var init_join_cli = __esm(async () => {
31041
+ await init_central();
31042
+ await init_enrollment();
31043
+ await init_join();
31044
+ });
31045
+
31046
+ // ../../adapters/otium/src/sidecar.ts
31047
+ var exports_sidecar = {};
31048
+ __export(exports_sidecar, {
31049
+ runOtiumSidecar: () => runOtiumSidecar,
31050
+ proxyOtiumPeerRequest: () => proxyOtiumPeerRequest
31051
+ });
31052
+ async function proxyOtiumPeerRequest(req, dependencies = {}) {
31053
+ const inspectNode = dependencies.inspectNode ?? inspectNodeDaemon;
31054
+ const fetchRequest = dependencies.fetch ?? fetch;
31055
+ const status = await inspectNode();
31056
+ if (!status.running || !status.info) {
31057
+ return Response.json({ ok: false, error: "canonical Negotium node is unavailable" }, { status: 503 });
31058
+ }
31059
+ const source = new URL(req.url);
31060
+ const target = new URL(`http://127.0.0.1:${status.info.port}`);
31061
+ target.pathname = `${OTIUM_ADAPTER_CONTROL_PREFIX}${source.pathname}`;
31062
+ target.search = source.search;
31063
+ const headers = new Headers(req.headers);
31064
+ headers.set(OTIUM_ADAPTER_CONTROL_HEADER, NODE_CONTROL_TOKEN);
31065
+ try {
31066
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
31067
+ return await fetchRequest(new Request(target.toString(), { method: req.method, headers, body, signal: req.signal }));
31068
+ } catch (error) {
31069
+ logger.warn({ err: error }, "otium sidecar: canonical node request failed");
31070
+ return Response.json({ ok: false, error: "canonical Negotium node connection failed" }, { status: 503 });
31071
+ }
31072
+ }
31073
+ async function runOtiumSidecar(options) {
31074
+ const join32 = loadJoin();
31075
+ if (!join32) {
31076
+ throw new Error("not joined to an Otium workspace \u2014 run `negotium otium join <code>` first");
31077
+ }
31078
+ const initialNode = await inspectNodeDaemon();
31079
+ if (!initialNode.running) {
31080
+ throw new Error("canonical Negotium node is not running");
31081
+ }
31082
+ const ready = await proxyOtiumPeerRequest(new Request("http://127.0.0.1/ready"));
31083
+ if (!ready.ok) {
31084
+ throw new Error("canonical Negotium node has no Otium runtime; restart it after joining the workspace");
31085
+ }
31086
+ let server;
31087
+ const lease = await waitForRequiredRuntimeProcessLease("adapter:otium", {
31088
+ workloadName: "Otium adapter",
31089
+ onLost: () => {
31090
+ process.stderr.write(`negotium otium: singleton lease lost; shutting down
31091
+ `);
31092
+ runShutdown("test");
31093
+ }
31094
+ });
31095
+ try {
31096
+ server = Bun.serve({
31097
+ port: options.port,
31098
+ hostname: "127.0.0.1",
31099
+ idleTimeout: 240,
31100
+ maxRequestBodySize: MAX_PEER_INPUT_REQUEST_BYTES,
31101
+ fetch: (req) => proxyOtiumPeerRequest(req)
31102
+ });
31103
+ } catch (error) {
31104
+ lease.stop();
31105
+ throw error;
31106
+ }
31107
+ const selectedRelay = options.relayUrl?.trim() || join32.relay || process.env.OTIUM_RELAY_URL?.trim();
31108
+ const tunnel = selectedRelay ? new TunnelClient({
31109
+ relayUrl: selectedRelay,
31110
+ token: join32.secret,
31111
+ targetOrigin: `http://127.0.0.1:${server.port}`,
31112
+ nodeVersion: `negotium@${NEGOTIUM_VERSION}`,
31113
+ logger
31114
+ }) : null;
31115
+ tunnel?.start();
31116
+ let resolveCompleted;
31117
+ const completed = new Promise((resolve15) => {
31118
+ resolveCompleted = resolve15;
31119
+ });
31120
+ onShutdown("otium-sidecar-server", 130, () => server?.stop(true));
31121
+ onShutdown("otium-sidecar-tunnel", 120, () => tunnel?.stop());
31122
+ onShutdown("otium-sidecar-lease", 110, () => lease.stop());
31123
+ onShutdown("otium-sidecar-completed", -100, resolveCompleted);
31124
+ process.stdout.write(`negotium Otium adapter listening on 127.0.0.1:${server.port} (canonical node pid ${initialNode.info?.pid})
31125
+ `);
31126
+ await completed;
31127
+ }
31128
+ var init_sidecar = __esm(async () => {
31129
+ await init_src();
31130
+ await init_src5();
31131
+ await init_join();
31132
+ init_protocol();
31133
+ init_tunnel_client();
31134
+ });
31135
+
30390
31136
  // ../../adapters/otium/src/cli.ts
30391
31137
  var exports_cli3 = {};
30392
31138
  __export(exports_cli3, {
@@ -30464,66 +31210,80 @@ async function resolveHostNodeId(explicit) {
30464
31210
  configureOtiumCentral2(null);
30465
31211
  }
30466
31212
  }
31213
+ async function spawnCanonicalNode2() {
31214
+ const entry = process.argv[1];
31215
+ if (!entry)
31216
+ throw new Error("cannot locate the Negotium CLI entrypoint");
31217
+ const { LOG_DIR: LOG_DIR2 } = await init_src().then(() => exports_src);
31218
+ const child = Bun.spawn({
31219
+ cmd: [process.execPath, entry, "__node-daemon", "--port=0"],
31220
+ detached: true,
31221
+ env: { ...process.env, LOG_LEVEL: process.env.NEGOTIUM_NODE_LOG_LEVEL?.trim() || "info" },
31222
+ stdin: "ignore",
31223
+ stdout: "ignore",
31224
+ stderr: Bun.file(`${LOG_DIR2}/node-daemon.log`)
31225
+ });
31226
+ child.unref();
31227
+ }
31228
+ async function ensureCanonicalNode2() {
31229
+ const { inspectNodeDaemon: inspectNodeDaemon2, waitForNodeDaemon: waitForNodeDaemon2 } = await init_src5().then(() => exports_src3);
31230
+ const status = await inspectNodeDaemon2();
31231
+ if (status.running)
31232
+ return;
31233
+ await spawnCanonicalNode2();
31234
+ await waitForNodeDaemon2(15000);
31235
+ }
31236
+ async function runCanonicalNodeChild2() {
31237
+ const { onShutdown: onShutdown2 } = await init_src().then(() => exports_src);
31238
+ const { MAX_PEER_INPUT_REQUEST_BYTES: MAX_PEER_INPUT_REQUEST_BYTES2, mountConfiguredOtiumNodeRuntime: mountConfiguredOtiumNodeRuntime2 } = await init_node_runtime().then(() => exports_node_runtime);
31239
+ const { runNodeDaemon: runNodeDaemon2 } = await init_src5().then(() => exports_src3);
31240
+ const runtime = mountConfiguredOtiumNodeRuntime2();
31241
+ if (runtime)
31242
+ onShutdown2("otium-node-runtime", 125, () => runtime.stop());
31243
+ await runNodeDaemon2({ port: 0, maxRequestBodySize: MAX_PEER_INPUT_REQUEST_BYTES2 });
31244
+ }
30467
31245
  async function runOtiumCli(args = process.argv.slice(2)) {
30468
31246
  const [command, ...commandArgs] = args;
30469
31247
  switch (command) {
31248
+ case "__node-daemon": {
31249
+ await runCanonicalNodeChild2();
31250
+ break;
31251
+ }
30470
31252
  case "join": {
30471
31253
  const { joinCommand: joinCommand2 } = await init_join_cli().then(() => exports_join_cli);
30472
31254
  await joinCommand2(commandArgs);
30473
31255
  break;
30474
31256
  }
31257
+ case "leave":
31258
+ case "disconnect": {
31259
+ if (commandArgs.length > 0)
31260
+ throw new Error(`usage: negotium otium ${command}`);
31261
+ if (process.env.OTIUM_CENTRAL_URL || process.env.OTIUM_CELL_ID || process.env.OTIUM_CELL_SECRET) {
31262
+ throw new Error("Otium join is configured by environment; remove OTIUM_CENTRAL_URL, OTIUM_CELL_ID, and OTIUM_CELL_SECRET to disconnect");
31263
+ }
31264
+ const { configureOtiumCentral: configureOtiumCentral2 } = await init_central().then(() => exports_central);
31265
+ const { loadJoin: loadJoin2, removeJoin: removeJoin2 } = await init_join().then(() => exports_join);
31266
+ const { disconnectSharedTopics: disconnectSharedTopics2 } = await init_shared_topic_sync().then(() => exports_shared_topic_sync);
31267
+ const join32 = loadJoin2();
31268
+ if (!join32)
31269
+ throw new Error("not joined to an Otium workspace");
31270
+ configureOtiumCentral2(join32);
31271
+ try {
31272
+ await disconnectSharedTopics2(join32);
31273
+ removeJoin2();
31274
+ } finally {
31275
+ configureOtiumCentral2(null);
31276
+ }
31277
+ console.log("disconnected from Otium; local shared topics are now private");
31278
+ break;
31279
+ }
30475
31280
  case "serve": {
30476
- const {
30477
- NEGOTIUM_PORT: NEGOTIUM_PORT2,
30478
- onShutdown: onShutdown2,
30479
- registerNodeRequestHandler: registerNodeRequestHandler2,
30480
- unregisterNodeRequestHandler: unregisterNodeRequestHandler2,
30481
- waitForRequiredRuntimeProcessLease: waitForRequiredRuntimeProcessLease2
30482
- } = await init_src().then(() => exports_src);
30483
- const [{ startDefaultNode: startDefaultNode2 }, otium] = await Promise.all([
30484
- init_src5().then(() => exports_src3),
30485
- init_src8().then(() => exports_src5)
30486
- ]);
31281
+ const { NEGOTIUM_PORT: NEGOTIUM_PORT2 } = await init_src().then(() => exports_src);
31282
+ const { runOtiumSidecar: runOtiumSidecar2 } = await init_sidecar().then(() => exports_sidecar);
30487
31283
  const port = parseOtiumServePort(commandArgs, NEGOTIUM_PORT2);
30488
31284
  const relayUrl = parseOtiumServeRelayUrl(commandArgs);
30489
- let node;
30490
- const singleton = await waitForRequiredRuntimeProcessLease2("adapter:otium", {
30491
- workloadName: "Otium adapter",
30492
- onLost: () => {
30493
- console.error("negotium otium: singleton lease lost; shutting down");
30494
- node?.stop();
30495
- }
30496
- });
30497
- const { handleOtiumPeerRequest: handleOtiumPeerRequest2, startOtiumWorker: startOtiumWorker2 } = otium;
30498
- const worker = startOtiumWorker2();
30499
- if (!worker) {
30500
- singleton.stop();
30501
- throw new Error("not joined to an otium workspace \u2014 run `negotium otium join <code>` first");
30502
- }
30503
- registerNodeRequestHandler2("otium-peer", handleOtiumPeerRequest2);
30504
- let workerStopped = false;
30505
- const stopWorker = () => {
30506
- if (workerStopped)
30507
- return;
30508
- workerStopped = true;
30509
- unregisterNodeRequestHandler2("otium-peer");
30510
- worker.stop();
30511
- };
30512
- onShutdown2("otium-worker", 100, stopWorker);
30513
- onShutdown2("otium-singleton", 90, () => singleton.stop());
30514
- try {
30515
- node = await startDefaultNode2({
30516
- port,
30517
- maxRequestBodySize: MAX_PEER_INPUT_REQUEST_BYTES
30518
- });
30519
- } catch (error) {
30520
- stopWorker();
30521
- singleton.stop();
30522
- throw error;
30523
- }
30524
- console.log(`negotium node (otium worker) listening on 127.0.0.1:${node.port} (ctrl-c to stop)`);
30525
- worker.startTunnel({ targetOrigin: `http://127.0.0.1:${node.port}`, relayUrl });
30526
- await node.completed;
31285
+ await ensureCanonicalNode2();
31286
+ await runOtiumSidecar2({ port, relayUrl });
30527
31287
  break;
30528
31288
  }
30529
31289
  case "bindings": {
@@ -30572,9 +31332,10 @@ async function runOtiumCli(args = process.argv.slice(2)) {
30572
31332
  console.log([
30573
31333
  "negotium otium \u2014 attach a Negotium node to an Otium workspace",
30574
31334
  "",
30575
- "usage: negotium otium <join|serve|bindings|share|private> [args]",
31335
+ "usage: negotium otium <join|leave|serve|bindings|share|private> [args]",
30576
31336
  "",
30577
31337
  " join <code> store credentials from an Otium invite code",
31338
+ " leave delete Hub copies, make local topics private, and remove credentials",
30578
31339
  " serve [--port <port>] [--relay <url>]",
30579
31340
  " run peer routes and an outbound relay tunnel",
30580
31341
  " bindings list internal mirrors and shared topic bindings",
@@ -30590,7 +31351,6 @@ async function runOtiumCli(args = process.argv.slice(2)) {
30590
31351
  }
30591
31352
  }
30592
31353
  var init_cli3 = __esm(() => {
30593
- init_protocol();
30594
31354
  if (false) {}
30595
31355
  });
30596
31356
 
@@ -31304,6 +32064,36 @@ function numericOption(values, name, fallback) {
31304
32064
  const parsed = Number.parseInt(values.find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? "", 10);
31305
32065
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
31306
32066
  }
32067
+ async function runCanonicalNode(port) {
32068
+ const { onShutdown: onShutdown2 } = await init_src().then(() => exports_src);
32069
+ const { MAX_PEER_INPUT_REQUEST_BYTES: MAX_PEER_INPUT_REQUEST_BYTES2, mountConfiguredOtiumNodeRuntime: mountConfiguredOtiumNodeRuntime2 } = await init_node_runtime().then(() => exports_node_runtime);
32070
+ const { startDefaultNode: startDefaultNode2 } = await init_src5().then(() => exports_src3);
32071
+ const otiumRuntime = mountConfiguredOtiumNodeRuntime2();
32072
+ if (otiumRuntime)
32073
+ onShutdown2("otium-node-runtime", 125, () => otiumRuntime.stop());
32074
+ const node = await startDefaultNode2({
32075
+ port,
32076
+ advertise: true,
32077
+ singleton: true,
32078
+ maxRequestBodySize: MAX_PEER_INPUT_REQUEST_BYTES2
32079
+ });
32080
+ console.log(`negotium node listening on 127.0.0.1:${node.port} (ctrl-c to stop)`);
32081
+ await node.completed;
32082
+ }
32083
+ async function stopAdapter(name) {
32084
+ const { getRuntimeProcessLease: getRuntimeProcessLease2 } = await init_src().then(() => exports_src);
32085
+ const lease = getRuntimeProcessLease2(`adapter:${name}`);
32086
+ if (!lease)
32087
+ return false;
32088
+ try {
32089
+ process.kill(lease.pid, "SIGTERM");
32090
+ return true;
32091
+ } catch (error) {
32092
+ if (error.code === "ESRCH")
32093
+ return false;
32094
+ throw error;
32095
+ }
32096
+ }
31307
32097
  switch (command) {
31308
32098
  case "init": {
31309
32099
  const { initCommand: initCommand2 } = await init_init().then(() => exports_init);
@@ -31316,22 +32106,20 @@ switch (command) {
31316
32106
  break;
31317
32107
  }
31318
32108
  case "serve": {
31319
- const { startDefaultNode: startDefaultNode2 } = await init_src5().then(() => exports_src3);
31320
- const node = await startDefaultNode2({
31321
- port: numericOption(args, "port", 7777),
31322
- advertise: true,
31323
- singleton: true
31324
- });
31325
- console.log(`negotium node listening on 127.0.0.1:${node.port} (ctrl-c to stop)`);
31326
- await node.completed;
32109
+ if (args[0] === "otium") {
32110
+ const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
32111
+ await runOtiumCli2(["serve", ...args.slice(1)]);
32112
+ break;
32113
+ }
32114
+ await runCanonicalNode(numericOption(args, "port", 7777));
31327
32115
  break;
31328
32116
  }
31329
32117
  case "__node-daemon": {
31330
- const { runNodeDaemon: runNodeDaemon2 } = await init_src5().then(() => exports_src3);
31331
- await runNodeDaemon2({ port: numericOption(args, "port", 0) });
32118
+ await runCanonicalNode(numericOption(args, "port", 0));
31332
32119
  break;
31333
32120
  }
31334
32121
  case "status": {
32122
+ const { listRuntimeProcessLeases: listRuntimeProcessLeases2 } = await init_src().then(() => exports_src);
31335
32123
  const { inspectNodeDaemon: inspectNodeDaemon2 } = await init_src5().then(() => exports_src3);
31336
32124
  const status = await inspectNodeDaemon2();
31337
32125
  if (status.running && status.info) {
@@ -31342,10 +32130,33 @@ switch (command) {
31342
32130
  } else {
31343
32131
  console.log("negotium node is stopped");
31344
32132
  }
32133
+ const adapters = listRuntimeProcessLeases2("adapter:");
32134
+ if (adapters.length === 0)
32135
+ console.log("adapters: none");
32136
+ else {
32137
+ for (const adapter of adapters) {
32138
+ console.log(`adapter ${adapter.role.slice("adapter:".length)} running (pid ${adapter.pid}, since ${new Date(adapter.startedAt).toISOString()})`);
32139
+ }
32140
+ }
31345
32141
  break;
31346
32142
  }
31347
32143
  case "stop": {
31348
32144
  const { stopNodeDaemon: stopNodeDaemon2 } = await init_src5().then(() => exports_src3);
32145
+ const target = args[0];
32146
+ if (target === "otium" || target === "telegram") {
32147
+ const stopped2 = await stopAdapter(target);
32148
+ console.log(stopped2 ? `${target} adapter shutdown requested` : `${target} adapter is not running`);
32149
+ break;
32150
+ }
32151
+ if (target && target !== "--all") {
32152
+ throw new Error("usage: negotium stop [otium|telegram|--all]");
32153
+ }
32154
+ if (target === "--all") {
32155
+ for (const name of ["otium", "telegram"]) {
32156
+ if (await stopAdapter(name))
32157
+ console.log(`${name} adapter shutdown requested`);
32158
+ }
32159
+ }
31349
32160
  const stopped = await stopNodeDaemon2();
31350
32161
  console.log(stopped ? "negotium node shutdown requested" : "negotium node is not running");
31351
32162
  break;
@@ -31377,11 +32188,14 @@ switch (command) {
31377
32188
  }
31378
32189
  case "telegram": {
31379
32190
  const { runTelegramCli: runTelegramCli2 } = await loadTelegramCli();
31380
- await runTelegramCli2();
32191
+ await runTelegramCli2(args);
31381
32192
  break;
31382
32193
  }
31383
32194
  case "otium": {
31384
32195
  const { runOtiumCli: runOtiumCli2 } = await loadOtiumCli();
32196
+ if (args[0] === "serve") {
32197
+ process.stderr.write("warning: `negotium otium serve` is deprecated; use `negotium serve otium`\n");
32198
+ }
31385
32199
  await runOtiumCli2(args);
31386
32200
  break;
31387
32201
  }
@@ -31400,9 +32214,10 @@ switch (command) {
31400
32214
  " init bootstrap ~/.negotium and check agent auth",
31401
32215
  " chat [topic] interactive chat (creates the topic if missing)",
31402
32216
  " options: --agent=claude|codex|maestro",
31403
- " serve foreground long-lived node (default port 7777)",
31404
- " status show the local long-lived node status",
31405
- " stop stop the local long-lived node",
32217
+ " serve foreground canonical node (default port 7777)",
32218
+ " serve otium ensure the canonical node and run the Otium sidecar",
32219
+ " status show the canonical node and adapter processes",
32220
+ " stop [otium|telegram|--all] stop the node, one adapter, or everything",
31406
32221
  " topics list topics on this node",
31407
32222
  " mcp list|add|remove|enable|disable manage node MCP manifest",
31408
32223
  " vault list|set|get|del node secret store (encrypted at rest)",
@@ -31420,4 +32235,4 @@ switch (command) {
31420
32235
  }
31421
32236
  }
31422
32237
 
31423
- //# debugId=4DB206AEE32F96CD64756E2164756E21
32238
+ //# debugId=17956C13E9ACAD6764756E2164756E21