@oar-sh/oar 0.9.15 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  5. package/server/claude-worker/claude-session-process.mjs +4 -4
  6. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  7. package/server/db-schema.mjs +52 -0
  8. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  9. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  10. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  11. package/server/migrations/0022-relay-schedules.mjs +142 -0
  12. package/server/migrations/0023-relay-asks.mjs +141 -0
  13. package/server/public/app/ask-user-view.js +3 -0
  14. package/server/public/app/bootstrap.js +61 -3
  15. package/server/public/app/context-usage-view.mjs +17 -0
  16. package/server/public/app/conversation-group-order.mjs +35 -0
  17. package/server/public/app/inbox-ui.js +356 -0
  18. package/server/public/app/inbox-view.mjs +370 -0
  19. package/server/public/app/journal-view.js +23 -2
  20. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  21. package/server/public/app/relay-event-view.mjs +33 -7
  22. package/server/public/app/relay-schedule-view.mjs +119 -0
  23. package/server/public/app/relay-schedules-ui.js +172 -0
  24. package/server/public/app/socket-handlers.js +27 -3
  25. package/server/public/app/store.js +18 -5
  26. package/server/public/index.html +159 -2
  27. package/server/relay-tools.md +18 -3
  28. package/server/repositories/conversation-group-repository.mjs +97 -11
  29. package/server/repositories/remote-relay-repository.mjs +20 -2
  30. package/server/repositories/session-repository.mjs +7 -1
  31. package/server/routes/ask-user-routes.mjs +57 -3
  32. package/server/routes/cache-routes.mjs +6 -0
  33. package/server/routes/conversation-group-routes.mjs +17 -2
  34. package/server/routes/messages-routes.mjs +3 -3
  35. package/server/routes/relay-ask-routes.mjs +387 -0
  36. package/server/routes/relay-event-routes.mjs +3 -1
  37. package/server/routes/relay-schedule-routes.mjs +57 -0
  38. package/server/routes/sessions-routes.mjs +568 -42
  39. package/server/server-runtime.mjs +80 -1
  40. package/server/services/cache-rebuild-service.mjs +19 -2
  41. package/server/services/conversation-preferences-service.mjs +17 -0
  42. package/server/services/relay-ask-link-service.mjs +174 -0
  43. package/server/services/relay-ask-service.mjs +604 -0
  44. package/server/services/relay-event-service.mjs +589 -73
  45. package/server/services/relay-schedule-service.mjs +596 -0
  46. package/server/services/remote-relay-caller-context.mjs +89 -20
  47. package/server/services/remote-relay-dispatcher.mjs +956 -30
  48. package/shared/relay-asks.mjs +145 -0
  49. package/shared/relay-events.mjs +268 -18
  50. package/shared/relay-schedules.mjs +128 -0
  51. package/shared/remote-relay-contract.mjs +553 -25
  52. package/shared/remote-relay-tool-core.mjs +16 -5
@@ -17,7 +17,7 @@ import {
17
17
  } from '../services/conversation-history-page.mjs';
18
18
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
19
19
  import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
20
- import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
20
+ import { persistConversationPreferences, readMayCreateSessions } from '../services/conversation-preferences-service.mjs';
21
21
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
22
22
  import {
23
23
  claudeBackgroundCommandLimitSettingsFields,
@@ -61,7 +61,7 @@ import { requestHostOf } from '../services/request-boundary.mjs';
61
61
  import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
62
62
  import { createShareRepository } from '../repositories/share-repository.mjs';
63
63
  import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
64
- import { resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
64
+ import { GROUP_NAME_MAX, resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
65
65
  import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
66
66
  import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
67
67
  import {
@@ -82,6 +82,14 @@ import {
82
82
  } from '../../shared/model-id.mjs';
83
83
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
84
84
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
85
+ import {
86
+ REMOTE_RELAY_ERROR_CODES,
87
+ REMOTE_RELAY_GROUP_LIMITS,
88
+ REMOTE_RELAY_SESSION_SETTINGS,
89
+ formatSessionGroupList,
90
+ resolveSessionGroupReference,
91
+ validateRemoteRelaySessionSettings,
92
+ } from '../../shared/remote-relay-contract.mjs';
85
93
  import {
86
94
  buildCloudSourceRecord,
87
95
  buildConversationCloudPayload,
@@ -2167,6 +2175,12 @@ export function registerSessionsRoutes(app, deps) {
2167
2175
  // Relay events: what each session is doing (state, tasks) and the
2168
2176
  // subscriptions that end with an archived or deleted conversation.
2169
2177
  relayEventService = null,
2178
+ // Relay schedules: the ones that fire into or were set by an archived or
2179
+ // deleted conversation end with it.
2180
+ relayScheduleService = null,
2181
+ // Questions that don't block: the pending ones in an archived or deleted
2182
+ // conversation end with it.
2183
+ relayAskService = null,
2170
2184
  sessionActivityTracker = null,
2171
2185
  // Public shares and their access log (migration 0016); built on `db` when
2172
2186
  // the wiring does not pass one.
@@ -2616,6 +2630,8 @@ export function registerSessionsRoutes(app, deps) {
2616
2630
  io.emit('conversation_deleted', { conversationId });
2617
2631
  if (leftGroupId) emitGroupChanged(leftGroupId, [conversationId]);
2618
2632
  try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2633
+ try { relayScheduleService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2634
+ try { relayAskService?.noteConversationGone?.(conversationId); } catch {}
2619
2635
  finalized.push(conversationId);
2620
2636
  }
2621
2637
  return finalized;
@@ -2641,6 +2657,8 @@ export function registerSessionsRoutes(app, deps) {
2641
2657
  parentConversationId: group.parentConversationId || null,
2642
2658
  memberCount: members.length,
2643
2659
  liveMemberCount: members.filter((member) => !member.archived).length,
2660
+ // An owner's group an agent emptied, which stays until the owner acts.
2661
+ keptEmptyAt: group.keptEmptyAt || null,
2644
2662
  };
2645
2663
  }
2646
2664
 
@@ -2702,6 +2720,38 @@ export function registerSessionsRoutes(app, deps) {
2702
2720
  });
2703
2721
  }
2704
2722
 
2723
+ // A session group named at bootstrap (an agent's create_session `group`):
2724
+ // what the lookup sees is what an agent may see (GET /api/conversation-groups).
2725
+ const autoTitle = (title) => resolveConversationTitle({ title, titleSource: 'auto' });
2726
+ const listGroupsForAgents = () => conversationGroups?.listGroups?.({ headTitle: autoTitle }) || [];
2727
+
2728
+ /** The 400 body for a group this relay does not have, or has several of by that name. */
2729
+ function bootstrapGroupRefusal(found, requested, groups) {
2730
+ const ambiguous = found.status === 'ambiguous';
2731
+ const listed = ambiguous ? found.matches : groups;
2732
+ const asked = String(requested || '').replace(/["\r\n]+/g, ' ').slice(0, 80);
2733
+ let error;
2734
+ if (ambiguous) error = `"${asked}" names several session groups here: ${formatSessionGroupList(listed)}. Pass the id.`;
2735
+ else if (groups.length) error = `No session group "${asked}" here. Groups: ${formatSessionGroupList(groups)}.`;
2736
+ else error = `No session group "${asked}" here: this relay has no session groups.`;
2737
+ return {
2738
+ ok: false,
2739
+ code: ambiguous ? 'SESSION_GROUP_AMBIGUOUS' : 'SESSION_GROUP_NOT_FOUND',
2740
+ error: `${error} No conversation was created.`,
2741
+ groups: listed.slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
2742
+ // How many there are when `groups` is cut to the cap.
2743
+ ...(listed.length > REMOTE_RELAY_GROUP_LIMITS.refusalMax ? { groupsTotal: listed.length } : {}),
2744
+ };
2745
+ }
2746
+
2747
+ /** `{ id, name, role }` of the group a conversation sits in (list_sessions' shape), or null. */
2748
+ function conversationGroupField(conversationId) {
2749
+ const membership = conversationGroups?.membershipsOf?.([conversationId])?.[conversationId];
2750
+ if (!membership?.groupId) return null;
2751
+ const described = conversationGroups.describeGroup?.(membership.groupId, { headTitle: autoTitle }) || null;
2752
+ return { id: membership.groupId, name: described?.displayName || null, role: membership.role === 'head' ? 'head' : 'member' };
2753
+ }
2754
+
2705
2755
  /** The `cloud` field of a conversation payload; null unless it is a cloud one. */
2706
2756
  function conversationCloudPayload(conversation, runtimeSession) {
2707
2757
  return buildConversationCloudPayload({
@@ -3116,6 +3166,8 @@ export function registerSessionsRoutes(app, deps) {
3116
3166
  draftUpdatedByClientId: r.draft_updated_by_client_id || null,
3117
3167
  // Set on conversations another relay's agent created ("via <relay>").
3118
3168
  origin: parseRemoteRelayOriginJson(r.origin_json),
3169
+ // The owner let this agent-started session's agents start sessions.
3170
+ mayCreateSessions: readMayCreateSessions(r),
3119
3171
  // Set on Claude Cloud conversations: repository, branch, session link.
3120
3172
  cloud: buildConversationCloudPayload({
3121
3173
  cloudSourceJson: r.cloud_source_json,
@@ -3140,6 +3192,14 @@ export function registerSessionsRoutes(app, deps) {
3140
3192
  for (const groupId of new Set(page.rows.map((row) => String(row?.group_id || '')).filter((id) => groups.has(id)))) {
3141
3193
  groupsPayload[groupId] = groupSummaryPayload(groups.get(groupId));
3142
3194
  }
3195
+ // The owner's groups an agent emptied have no row to come with: the live
3196
+ // list's first page brings them, so the sidebar shows them as empty
3197
+ // groups the owner can rename or dissolve.
3198
+ if (archivedMode !== 'only' && !beforeConversationId) {
3199
+ for (const group of conversationGroups?.listKeptEmptyGroups?.() || []) {
3200
+ groupsPayload[group.id] = groupSummaryPayload(group);
3201
+ }
3202
+ }
3143
3203
 
3144
3204
  return res.json({
3145
3205
  conversations,
@@ -3862,6 +3922,7 @@ export function registerSessionsRoutes(app, deps) {
3862
3922
  draftUpdatedAt: conv.draft_updated_at || null,
3863
3923
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3864
3924
  origin: parseRemoteRelayOriginJson(conv.origin_json),
3925
+ mayCreateSessions: readMayCreateSessions(conv),
3865
3926
  cloud: conversationCloudPayload(conv, runtimeSession),
3866
3927
  group: conversationGroupPayload(conversationId),
3867
3928
  groupPosition: conversationGroupPositionOf(conv),
@@ -4563,6 +4624,7 @@ export function registerSessionsRoutes(app, deps) {
4563
4624
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
4564
4625
  // Set on conversations another relay's agent created ("via <relay>").
4565
4626
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4627
+ mayCreateSessions: readMayCreateSessions(conv),
4566
4628
  // Set on Claude Cloud conversations: repository, branch, session link.
4567
4629
  cloud: conversationCloudPayload(conv, runtimeSession),
4568
4630
  // The session group it is in, with the other members; null when none.
@@ -4738,42 +4800,36 @@ export function registerSessionsRoutes(app, deps) {
4738
4800
  });
4739
4801
  });
4740
4802
 
4741
- app.patch('/api/conversation/:id/preferences', auth, (req, res) => {
4742
- const conversationId = String(req.params.id || '').trim();
4743
- if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
4744
- const senderClientId = String(req.body?.clientId || '').trim() || null;
4745
-
4746
- const existing = stmts.getConvAnyStatus.get(conversationId);
4747
- if (existing && String(existing.status || '').trim() === 'deleted') {
4748
- return res.status(404).json({ error: 'Conversation not found' });
4749
- }
4750
-
4803
+ /**
4804
+ * Writes a conversation's preferences and tells every open page: the one
4805
+ * code path of PATCH …/preferences (the composer, the context modal) and of
4806
+ * an agent's update_session (PATCH …/settings). `body` uses the preferences
4807
+ * route's keys; a key it does not mention keeps the stored value.
4808
+ */
4809
+ function applyConversationPreferences({ conversationId, existing, body = {}, senderClientId = null }) {
4751
4810
  const now = new Date().toISOString();
4752
4811
  // Every write replaces all four columns, so a caller that only wants to
4753
4812
  // change one field (the context modal's auto-compact slider) must not have
4754
4813
  // the others reset to their defaults: an unmentioned key keeps the stored
4755
4814
  // value. The composer always sends all three, so its behaviour — including
4756
4815
  // clearing a preference with an explicit '' — is unchanged.
4757
- const mentions = (key) => Object.prototype.hasOwnProperty.call(req.body || {}, key);
4816
+ const mentions = (key) => Object.prototype.hasOwnProperty.call(body || {}, key);
4758
4817
  const preferredRelayMode = normalizeRelayModePreference(
4759
- mentions('preferredRelayMode') ? req.body?.preferredRelayMode : existing?.preferred_relay_mode,
4818
+ mentions('preferredRelayMode') ? body.preferredRelayMode : existing?.preferred_relay_mode,
4760
4819
  {
4761
4820
  supportedRelayModes: SUPPORTED_RELAY_MODES,
4762
4821
  fallbackMode: DEFAULT_RELAY_MODE,
4763
4822
  },
4764
4823
  );
4765
4824
  const preferredModel = normalizePreferredModel(
4766
- mentions('preferredModel') ? req.body?.preferredModel : existing?.preferred_model,
4825
+ mentions('preferredModel') ? body.preferredModel : existing?.preferred_model,
4767
4826
  );
4768
4827
  const preferredReasoningEffort = normalizePreferredReasoningEffort(
4769
- mentions('preferredReasoningEffort') ? req.body?.preferredReasoningEffort : existing?.preferred_reasoning_effort,
4828
+ mentions('preferredReasoningEffort') ? body.preferredReasoningEffort : existing?.preferred_reasoning_effort,
4770
4829
  );
4771
4830
  // Only an explicit mention rewrites the window (null = Auto is a real
4772
4831
  // value, so a plain falsy check would not do). Same for the thinking
4773
4832
  // control, where null = Host default.
4774
- const mentionsAutoCompactWindow = mentions('autoCompactWindow');
4775
- const mentionsThinkingEnabled = mentions('thinkingEnabled');
4776
- const mentionsThinkingDisplay = mentions('thinkingDisplay');
4777
4833
  const persisted = persistConversationPreferences({
4778
4834
  db,
4779
4835
  stmts,
@@ -4781,21 +4837,21 @@ export function registerSessionsRoutes(app, deps) {
4781
4837
  preferredRelayMode,
4782
4838
  preferredModel,
4783
4839
  preferredReasoningEffort,
4784
- ...(mentionsAutoCompactWindow
4785
- ? { autoCompactWindow: parseAutoCompactWindow(req.body?.autoCompactWindow) }
4840
+ ...(mentions('autoCompactWindow')
4841
+ ? { autoCompactWindow: parseAutoCompactWindow(body.autoCompactWindow) }
4786
4842
  : {}),
4787
- ...(mentionsThinkingEnabled
4788
- ? { thinkingEnabled: parseThinkingEnabled(req.body?.thinkingEnabled) }
4843
+ ...(mentions('thinkingEnabled')
4844
+ ? { thinkingEnabled: parseThinkingEnabled(body.thinkingEnabled) }
4789
4845
  : {}),
4790
- ...(mentionsThinkingDisplay
4791
- ? { thinkingDisplay: parseThinkingDisplay(req.body?.thinkingDisplay) }
4846
+ ...(mentions('thinkingDisplay')
4847
+ ? { thinkingDisplay: parseThinkingDisplay(body.thinkingDisplay) }
4792
4848
  : {}),
4849
+ ...(mentions('mayCreateSessions') ? { mayCreateSessions: body.mayCreateSessions === true } : {}),
4793
4850
  updatedAt: now,
4794
4851
  createIfMissing: !existing,
4795
4852
  createTitle: 'Session',
4796
4853
  });
4797
-
4798
- io.emit('conversation_preferences_updated', {
4854
+ const payload = {
4799
4855
  conversationId,
4800
4856
  preferredRelayMode: persisted.preferredRelayMode,
4801
4857
  preferredModel: persisted.preferredModel,
@@ -4803,22 +4859,447 @@ export function registerSessionsRoutes(app, deps) {
4803
4859
  autoCompactWindow: persisted.autoCompactWindow ?? null,
4804
4860
  thinkingEnabled: persisted.thinkingEnabled ?? null,
4805
4861
  thinkingDisplay: persisted.thinkingDisplay ?? null,
4862
+ mayCreateSessions: persisted.mayCreateSessions === true,
4806
4863
  updatedAt: persisted.updatedAt,
4807
4864
  senderClientId,
4865
+ };
4866
+ io.emit('conversation_preferences_updated', payload);
4867
+ return { ...payload, created: persisted.created };
4868
+ }
4869
+
4870
+ app.patch('/api/conversation/:id/preferences', auth, (req, res) => {
4871
+ const conversationId = String(req.params.id || '').trim();
4872
+ if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
4873
+ const senderClientId = String(req.body?.clientId || '').trim() || null;
4874
+
4875
+ const existing = stmts.getConvAnyStatus.get(conversationId);
4876
+ if (existing && String(existing.status || '').trim() === 'deleted') {
4877
+ return res.status(404).json({ error: 'Conversation not found' });
4878
+ }
4879
+ // "Agents in this conversation may start sessions" is the owner's alone:
4880
+ // an agent (of this relay or a paired one) could grant it to itself.
4881
+ if (Object.prototype.hasOwnProperty.call(req.body || {}, 'mayCreateSessions')) {
4882
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
4883
+ return res.status(403).json({ error: 'Whether agents in a conversation may start sessions is the owner\'s setting alone.' });
4884
+ }
4885
+ if (typeof req.body.mayCreateSessions !== 'boolean') {
4886
+ return res.status(400).json({ error: 'mayCreateSessions must be true or false' });
4887
+ }
4888
+ if (!stmts.conversationsHaveMayCreateSessions) {
4889
+ return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
4890
+ }
4891
+ }
4892
+
4893
+ const persisted = applyConversationPreferences({ conversationId, existing, body: req.body || {}, senderClientId });
4894
+ return res.json({ ok: true, ...persisted });
4895
+ });
4896
+
4897
+ // ─── PATCH /api/conversation/:id/settings — the remote_relay tool's update_session ───
4898
+ //
4899
+ // `{ settings: { model, effort, mode, auto_compact_window, title, group,
4900
+ // thinking_enabled, thinking_display, cwd }, dry_run, owner_approved }`.
4901
+ // Reached by an agent of this relay (the loopback) and by a paired relay's
4902
+ // agent (under the inbound switch); the owner may call it too. It applies
4903
+ // this relay's own rule: an agent changes freely only a session its own
4904
+ // conversation started (the request's origin against the session's); any
4905
+ // other changes only with `owner_approved` (the calling relay asked its
4906
+ // user). A dry run checks everything, writes nothing, and says whether
4907
+ // approval is needed. A group the owner made never goes through this path
4908
+ // (it may stay empty); one an agent made here goes once empty. A step that
4909
+ // fails after an earlier one wrote answers `changed` with what was written.
4910
+ // Every write goes through the code the owner's
4911
+ // own controls use (preferences, title, group, workspace root), with their
4912
+ // socket events, so an open page shows the change at once. The owner's
4913
+ // "may start sessions" switch is not among the settings.
4914
+
4915
+ const SESSION_SETTINGS_INVALID = 'SESSION_SETTINGS_INVALID';
4916
+
4917
+ function currentSessionSettings(conversationId) {
4918
+ const conv = stmts.getConvAnyStatus.get(conversationId) || {};
4919
+ const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
4920
+ const workspace = resolveConversationWorkspaceState?.({ conversationId }) || null;
4921
+ const window = parseAutoCompactWindow(conv.auto_compact_window);
4922
+ return {
4923
+ model: normalizePreferredModel(conv.preferred_model) || String(runtime?.provider_model || runtime?.model || '').trim() || null,
4924
+ effort: normalizePreferredReasoningEffort(conv.preferred_reasoning_effort) || null,
4925
+ mode: normalizeRelayModePreference(conv.preferred_relay_mode, { supportedRelayModes: SUPPORTED_RELAY_MODES, fallbackMode: DEFAULT_RELAY_MODE }),
4926
+ auto_compact_window: window === null ? 'auto' : window,
4927
+ title: resolveConversationTitle({ title: conv.title, titleSource: conv.title_source }) || null,
4928
+ group: conversationGroupField(conversationId),
4929
+ thinking_enabled: parseThinkingEnabled(conv.thinking_enabled),
4930
+ thinking_display: parseThinkingDisplay(conv.thinking_display),
4931
+ cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
4932
+ };
4933
+ }
4934
+
4935
+ /**
4936
+ * Whether the agent calling the settings route started this session from
4937
+ * its own conversation: the session's origin names that conversation, and
4938
+ * this relay's own agent (the loopback) for a local origin, the same
4939
+ * paired relay for one from there.
4940
+ */
4941
+ function sessionStartedByCaller(createdBy, remoteRequest) {
4942
+ const caller = remoteRequest?.remote ? remoteRequest.origin : null;
4943
+ if (!createdBy || !caller) return false;
4944
+ const callerConversation = String(caller.conversationId || '').trim();
4945
+ if (!callerConversation || String(createdBy.conversationId || '').trim() !== callerConversation) return false;
4946
+ if (remoteRequest.local === true) return createdBy.local === true && caller.local === true;
4947
+ const relayId = String(createdBy.relayId || '').trim();
4948
+ return createdBy.local !== true && !!relayId && relayId === String(caller.relayId || '').trim();
4949
+ }
4950
+
4951
+ /** Why a session cannot move to another folder now ('' when it can). */
4952
+ function sessionBusyReason(conversationId) {
4953
+ if (Number(countActiveConversationQueueRows.get(conversationId)?.count || 0) > 0) return 'a turn is queued or running there';
4954
+ const state = describeSessionActivity(conversationId)?.sessionState;
4955
+ if (state === 'running_turn') return 'a turn is running there';
4956
+ if (state === 'background_only') return 'a background task is running there';
4957
+ return '';
4958
+ }
4959
+
4960
+ /**
4961
+ * What update_session would do: `{ ok, plan }` or `{ ok:false, status,
4962
+ * body }`. The plan holds what changes (`changed`, in the order of the
4963
+ * contract's settings) and how.
4964
+ */
4965
+ function planSessionSettings(conversation, raw) {
4966
+ const conversationId = String(conversation.id);
4967
+ const fail = (status, error, extra = {}) => ({ ok: false, status, body: { ok: false, error, ...extra } });
4968
+ const settingsInput = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
4969
+ if (!settingsInput) return fail(400, 'settings must be an object', { code: SESSION_SETTINGS_INVALID });
4970
+ const unknown = Object.keys(settingsInput).filter((key) => !REMOTE_RELAY_SESSION_SETTINGS.includes(key));
4971
+ if (unknown.length) {
4972
+ return fail(400, `Not a setting an agent may change: ${unknown.slice(0, 10).join(', ')}. Settings: ${REMOTE_RELAY_SESSION_SETTINGS.join(', ')}.`, { code: SESSION_SETTINGS_INVALID });
4973
+ }
4974
+ const checked = validateRemoteRelaySessionSettings(settingsInput);
4975
+ if (!checked.ok) return fail(400, checked.error, { code: SESSION_SETTINGS_INVALID });
4976
+ const wanted = checked.values;
4977
+ const current = currentSessionSettings(conversationId);
4978
+ const runtime = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
4979
+ const provider = String(runtime?.provider_type || 'github').trim().toLowerCase() || 'github';
4980
+ const plan = { conversationId, provider, current, wanted, changed: [], preferences: {}, notes: [] };
4981
+ const change = (key) => { if (!plan.changed.includes(key)) plan.changed.push(key); };
4982
+
4983
+ if (wanted.model !== undefined && normalizePreferredModel(wanted.model) !== normalizePreferredModel(conversation.preferred_model)) {
4984
+ plan.preferences.preferredModel = wanted.model;
4985
+ change('model');
4986
+ }
4987
+ if (wanted.effort !== undefined && wanted.effort !== normalizePreferredReasoningEffort(conversation.preferred_reasoning_effort)) {
4988
+ plan.preferences.preferredReasoningEffort = wanted.effort;
4989
+ change('effort');
4990
+ }
4991
+ if (wanted.mode !== undefined) {
4992
+ if (!SUPPORTED_RELAY_MODES.includes(wanted.mode)) {
4993
+ return fail(400, `This relay has no mode "${wanted.mode}". Modes: ${SUPPORTED_RELAY_MODES.join(', ')}.`, { code: SESSION_SETTINGS_INVALID });
4994
+ }
4995
+ if (wanted.mode !== current.mode) {
4996
+ plan.preferences.preferredRelayMode = wanted.mode;
4997
+ change('mode');
4998
+ }
4999
+ }
5000
+ if (wanted.auto_compact_window !== undefined) {
5001
+ const window = wanted.auto_compact_window === 'auto' ? null : parseAutoCompactWindow(wanted.auto_compact_window);
5002
+ if (window !== parseAutoCompactWindow(conversation.auto_compact_window)) {
5003
+ plan.preferences.autoCompactWindow = window;
5004
+ change('auto_compact_window');
5005
+ }
5006
+ if (window !== null && window !== wanted.auto_compact_window) {
5007
+ plan.notes.push(`auto_compact_window ${wanted.auto_compact_window} was taken as ${window}, the nearest window this relay offers.`);
5008
+ }
5009
+ }
5010
+ if (wanted.thinking_enabled !== undefined && wanted.thinking_enabled !== current.thinking_enabled) {
5011
+ plan.preferences.thinkingEnabled = wanted.thinking_enabled;
5012
+ change('thinking_enabled');
5013
+ }
5014
+ if (wanted.thinking_display !== undefined && wanted.thinking_display !== current.thinking_display) {
5015
+ plan.preferences.thinkingDisplay = wanted.thinking_display;
5016
+ change('thinking_display');
5017
+ }
5018
+ if (provider !== 'claude' && ['auto_compact_window', 'thinking_enabled', 'thinking_display'].some((key) => wanted[key] !== undefined)) {
5019
+ plan.notes.push(`auto_compact_window and thinking apply to Claude sessions only; this one runs on ${provider}, so they are stored but change nothing there.`);
5020
+ }
5021
+ if (wanted.title !== undefined) {
5022
+ const title = normalizeConversationTitle(wanted.title);
5023
+ if (title !== current.title) {
5024
+ plan.title = title;
5025
+ change('title');
5026
+ }
5027
+ }
5028
+ if (Object.prototype.hasOwnProperty.call(wanted, 'group')) {
5029
+ if (!conversationGroups?.enabled) {
5030
+ return fail(503, 'Session groups are not available on this relay (its storage for them is missing). Nothing was changed.', { code: 'SESSION_GROUPS_UNAVAILABLE' });
5031
+ }
5032
+ if (wanted.group === null) {
5033
+ if (current.group) {
5034
+ plan.group = { action: 'leave', previous: current.group };
5035
+ change('group');
5036
+ }
5037
+ } else {
5038
+ const groups = listGroupsForAgents();
5039
+ const found = resolveSessionGroupReference(groups, wanted.group);
5040
+ if (found.status === 'ambiguous') {
5041
+ const refusal = bootstrapGroupRefusal(found, wanted.group, groups);
5042
+ return fail(400, refusal.error.replace('No conversation was created.', 'Nothing was changed.'), { code: refusal.code, groups: refusal.groups, ...(refusal.groupsTotal ? { groupsTotal: refusal.groupsTotal } : {}) });
5043
+ }
5044
+ if (found.status === 'found') {
5045
+ if (current.group?.id !== found.group.id) {
5046
+ plan.group = { action: 'join', id: found.group.id, name: found.group.name };
5047
+ change('group');
5048
+ }
5049
+ } else if (/^grp_/i.test(wanted.group)) {
5050
+ // An id that is not there names no group to make.
5051
+ const refusal = bootstrapGroupRefusal(found, wanted.group, groups);
5052
+ return fail(400, refusal.error.replace('No conversation was created.', 'Nothing was changed.'), { code: refusal.code, groups: refusal.groups, ...(refusal.groupsTotal ? { groupsTotal: refusal.groupsTotal } : {}) });
5053
+ } else if (wanted.group.replace(/\s+/g, ' ').length > GROUP_NAME_MAX) {
5054
+ return fail(400, `A group name has at most ${GROUP_NAME_MAX} characters. Nothing was changed.`, { code: SESSION_SETTINGS_INVALID });
5055
+ } else {
5056
+ plan.group = { action: 'create', name: wanted.group.replace(/\s+/g, ' ') };
5057
+ change('group');
5058
+ }
5059
+ }
5060
+ }
5061
+ if (wanted.cwd !== undefined) {
5062
+ if (provider === 'claude-cloud') {
5063
+ return fail(400, 'cwd does not apply to a Claude Cloud session: it works on a clone of its repository. Nothing was changed.', { code: SESSION_SETTINGS_INVALID });
5064
+ }
5065
+ if (typeof updateConversationConfiguredWorkspaceRoot !== 'function') {
5066
+ return fail(503, 'Conversation workspace updates are unavailable on this relay. Nothing was changed.', { code: SESSION_SETTINGS_INVALID });
5067
+ }
5068
+ const validated = validateRequestedWorkspaceRoot(wanted.cwd, { allowList: workspaceRootAllowList });
5069
+ if (!validated.ok) {
5070
+ return fail(validated.code === 'root-path-not-allowed' ? 403 : 400, `${validated.error || 'cwd is not a folder this relay may use'}. Nothing was changed.`, { code: validated.code || SESSION_SETTINGS_INVALID });
5071
+ }
5072
+ if (normalizeWorkspaceRootKey(validated.realPath) !== normalizeWorkspaceRootKey(current.cwd || '')) {
5073
+ const busy = sessionBusyReason(conversationId);
5074
+ if (busy) {
5075
+ return fail(409, `The session cannot move to another folder now: ${busy}. Wait for it to finish (wait or read_session), then try again. Nothing was changed.`, { code: 'SESSION_BUSY', reason: busy });
5076
+ }
5077
+ plan.cwd = validated.realPath;
5078
+ change('cwd');
5079
+ }
5080
+ }
5081
+ plan.changed.sort((a, b) => REMOTE_RELAY_SESSION_SETTINGS.indexOf(a) - REMOTE_RELAY_SESSION_SETTINGS.indexOf(b));
5082
+ return { ok: true, plan };
5083
+ }
5084
+
5085
+ /** The session's folder through the owner's CWD path: saved, and its CLI relaunched there when it has one. */
5086
+ async function applySessionCwd(plan, req) {
5087
+ const { conversationId } = plan;
5088
+ const rateLimited = consumeWorkspaceRootRateLimit(conversationId, req);
5089
+ if (!rateLimited.ok) {
5090
+ return { ok: false, status: 429, body: { ok: false, code: 'rate-limited', error: 'Too many CWD updates. Try again shortly. Nothing was changed.' } };
5091
+ }
5092
+ const sdkSessionId = String(resolveConversationWorkspaceState?.({ conversationId })?.sdkSessionId || '').trim();
5093
+ if (!sdkSessionId) {
5094
+ // Never launched: the folder is saved for its first launch.
5095
+ const saved = await sessionLaunchMutex.runExclusive(`conv:${conversationId}`, () => {
5096
+ const busy = sessionBusyReason(conversationId);
5097
+ return busy ? { ok: false, busyReason: busy } : updateConversationConfiguredWorkspaceRoot({ conversationId, rootPath: plan.cwd });
5098
+ });
5099
+ if (saved?.busyReason) {
5100
+ return { ok: false, status: 409, body: { ok: false, code: 'SESSION_BUSY', reason: saved.busyReason, error: `The session cannot move to another folder now: ${saved.busyReason}. Wait for it to finish, then try again. Nothing was changed.` } };
5101
+ }
5102
+ if (!saved?.ok) return { ok: false, status: 400, body: { ok: false, error: `${saved?.error || 'Failed to update the session\'s folder'}. Nothing was changed.` } };
5103
+ const state = saved.state || null;
5104
+ io.emit('conversation_workspace_root_updated', {
5105
+ conversationId,
5106
+ sdkSessionId: state?.sdkSessionId || null,
5107
+ configuredWorkspaceRootPath: state?.configuredWorkspaceRootPath || null,
5108
+ configuredWorkspaceRootName: state?.configuredWorkspaceRootName || null,
5109
+ runtimeWorkspaceRootPath: state?.runtimeWorkspaceRootPath || null,
5110
+ runtimeWorkspaceRootName: state?.runtimeWorkspaceRootName || null,
5111
+ currentWorkspaceRootPath: state?.currentWorkspaceRootPath || null,
5112
+ currentWorkspaceRootName: state?.currentWorkspaceRootName || null,
5113
+ recentWorkspaceRoots: Array.isArray(workspaceRootPayload()?.recentWorkspaceRoots) ? workspaceRootPayload().recentWorkspaceRoots : [],
5114
+ });
5115
+ return { ok: true, relaunched: false };
5116
+ }
5117
+ let attempt;
5118
+ try {
5119
+ attempt = await sessionLaunchMutex.tryRunExclusive(sdkSessionId, () => {
5120
+ // Asked again under the lock, right before the relaunch: a turn or a
5121
+ // task may have started since the plan.
5122
+ const busy = sessionBusyReason(conversationId);
5123
+ if (busy) return { busyReason: busy };
5124
+ return runWorkspaceRootRelaunch({ conversationId, sdkSessionId, rootPath: plan.cwd });
5125
+ });
5126
+ } catch (error) {
5127
+ return { ok: false, status: 500, body: { ok: false, code: 'relaunch-error', error: error?.message || 'Failed to relaunch the CLI' } };
5128
+ }
5129
+ if (attempt.busy) {
5130
+ return { ok: false, status: 409, body: { ok: false, code: 'SESSION_BUSY', reason: 'its CLI is being relaunched', error: 'The session cannot move to another folder now: its CLI is being relaunched. Try again shortly. Nothing was changed.' } };
5131
+ }
5132
+ const outcome = attempt.result || {};
5133
+ if (outcome.busyReason) {
5134
+ return { ok: false, status: 409, body: { ok: false, code: 'SESSION_BUSY', reason: outcome.busyReason, error: `The session cannot move to another folder now: ${outcome.busyReason}. Wait for it to finish, then try again. Nothing was changed.` } };
5135
+ }
5136
+ // The relay could not read its process list, so it cannot tell whether
5137
+ // the session's CLI still runs: nothing is stopped or started.
5138
+ if (/^process-list-unreadable/.test(String(outcome.error || ''))) {
5139
+ return {
5140
+ ok: false,
5141
+ status: 503,
5142
+ body: {
5143
+ ok: false,
5144
+ code: 'PROCESS_LIST_UNREADABLE',
5145
+ error: 'This relay could not read its process list, so it cannot tell whether the session\'s CLI still runs; the folder was not changed. Try again shortly. Nothing was changed.',
5146
+ },
5147
+ };
5148
+ }
5149
+ if (outcome.statusCode !== 200 || outcome.body?.ok !== true) {
5150
+ const body = outcome.body || {};
5151
+ const busy = body.code === 'relaunch-not-eligible';
5152
+ // A CLI that would not stop or start: the folder is saved for the next
5153
+ // launch all the same (the owner's route does the same), so cwd changed.
5154
+ const saved = !busy && !!body.configuredWorkspaceRootPath;
5155
+ return {
5156
+ ok: false,
5157
+ status: outcome.statusCode || 500,
5158
+ body: {
5159
+ ...body,
5160
+ ok: false,
5161
+ ...(busy ? { code: 'SESSION_BUSY', reason: 'a turn is queued or running there' } : {}),
5162
+ error: busy
5163
+ ? `The session cannot move to another folder now: ${body.error || 'it is busy'}. Nothing was changed.`
5164
+ : `${body.error || 'Failed to relaunch the CLI'}${saved ? ' The folder is saved for its next launch; nothing else was changed.' : ' Nothing was changed.'}`,
5165
+ changed: saved ? ['cwd'] : [],
5166
+ },
5167
+ };
5168
+ }
5169
+ return {
5170
+ ok: true,
5171
+ relaunched: outcome.body.relaunched === true,
5172
+ ...(outcome.body.relaunched === true ? {} : { warning: outcome.body.message || 'The folder is saved for the next launch, but the running CLI kept its current folder.' }),
5173
+ };
5174
+ }
5175
+
5176
+ app.patch('/api/conversation/:id/settings', auth, async (req, res) => {
5177
+ const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
5178
+ if (!remoteRequest) return;
5179
+ const requestedId = String(req.params.id || '').trim();
5180
+ const conversation = requestedId ? resolveConversationByIdOrSdkSessionId(requestedId) : null;
5181
+ if (!conversation || String(conversation.status || '').trim() === 'deleted') {
5182
+ return res.status(404).json({ ok: false, code: 'SESSION_NOT_FOUND', error: 'Conversation not found' });
5183
+ }
5184
+ const conversationId = String(conversation.id);
5185
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
5186
+ const planned = planSessionSettings(conversation, body.settings);
5187
+ if (!planned.ok) return res.status(planned.status).json(planned.body);
5188
+ const { plan } = planned;
5189
+ const createdBy = parseRemoteRelayOriginJson(conversation.origin_json);
5190
+ const byCaller = sessionStartedByCaller(createdBy, remoteRequest);
5191
+ // An agent changes freely only a session its own conversation started;
5192
+ // any other (the owner's, another agent's, its own creator's) needs the
5193
+ // owner's Allow, which the calling relay asks for and states.
5194
+ const approvalNeeded = remoteRequest.remote === true && !byCaller;
5195
+ const facts = { session: conversationId, provider: plan.provider, ownerStarted: !createdBy, startedByCaller: byCaller, approvalNeeded };
5196
+ if (body.dry_run === true) {
5197
+ return res.json({
5198
+ ok: true,
5199
+ dryRun: true,
5200
+ ...facts,
5201
+ settings: plan.current,
5202
+ changed: plan.changed,
5203
+ ...(plan.group ? { group: plan.group } : {}),
5204
+ ...(plan.notes.length ? { notes: plan.notes } : {}),
5205
+ });
5206
+ }
5207
+ // This relay's own rule, whatever the calling relay checked.
5208
+ if (approvalNeeded && body.owner_approved !== true) {
5209
+ return res.status(403).json({
5210
+ ok: false,
5211
+ code: REMOTE_RELAY_ERROR_CODES.permission,
5212
+ ...facts,
5213
+ error: 'This session was not started by the calling conversation: an agent changes its settings only after the relay\'s owner allowed that for the calling conversation.',
5214
+ });
5215
+ }
5216
+ if (!plan.changed.length) {
5217
+ return res.json({ ok: true, ...facts, settings: plan.current, changed: [], ...(plan.notes.length ? { notes: plan.notes } : {}) });
5218
+ }
5219
+
5220
+ // What is written so far: a step that fails later answers it, so a
5221
+ // partial change is never passed off as none.
5222
+ const written = [];
5223
+ const failAfter = (status, payload) => res.status(status).json({
5224
+ ...payload,
5225
+ ok: false,
5226
+ error: `${payload.error}${written.length ? ` These settings were changed before: ${written.join(', ')}.` : ' Nothing was changed.'}`,
5227
+ changed: [...written],
4808
5228
  });
5229
+ // The folder first: it is the one change that can fail after the checks
5230
+ // (a CLI that does not stop or start), and then nothing else is written.
5231
+ let cwdResult = null;
5232
+ if (plan.cwd) {
5233
+ cwdResult = await applySessionCwd(plan, req);
5234
+ if (!cwdResult.ok) return res.status(cwdResult.status).json(cwdResult.body);
5235
+ written.push('cwd');
5236
+ }
5237
+ if (plan.title !== undefined) {
5238
+ const titled = persistConversationTitle({ db, stmts, io, conversationId, title: plan.title, resolveSessionStateRoot });
5239
+ if (!titled.ok) return failAfter(titled.statusCode || 500, { error: titled.error || 'Failed to update the title.' });
5240
+ written.push('title');
5241
+ }
5242
+ if (Object.keys(plan.preferences).length) {
5243
+ applyConversationPreferences({
5244
+ conversationId,
5245
+ existing: stmts.getConvAnyStatus.get(conversationId),
5246
+ body: plan.preferences,
5247
+ });
5248
+ written.push(...plan.changed.filter((key) => !['cwd', 'title', 'group'].includes(key)));
5249
+ }
5250
+ // Groups through the agent path: an owner's group the session leaves is
5251
+ // never removed (it stays, empty if it was the last member); a group an
5252
+ // agent made goes once empty.
5253
+ const groupOptions = { keepOwnerGroups: remoteRequest.remote === true };
5254
+ let groupCreated = null;
5255
+ let groupPlan = plan.group;
5256
+ if (groupPlan?.action === 'create') {
5257
+ // Looked up once more right before it is made: a call that ran beside
5258
+ // this one may have made the group of that name meanwhile.
5259
+ const groups = listGroupsForAgents();
5260
+ const found = resolveSessionGroupReference(groups, groupPlan.name);
5261
+ if (found.status === 'found') groupPlan = { action: 'join', id: found.group.id, name: found.group.name };
5262
+ // Several of that name by now: refused as the first lookup refuses.
5263
+ if (found.status === 'ambiguous') {
5264
+ const refusal = bootstrapGroupRefusal(found, groupPlan.name, groups);
5265
+ return failAfter(400, {
5266
+ code: refusal.code,
5267
+ error: refusal.error.replace(' No conversation was created.', ''),
5268
+ groups: refusal.groups,
5269
+ ...(refusal.groupsTotal ? { groupsTotal: refusal.groupsTotal } : {}),
5270
+ });
5271
+ }
5272
+ }
5273
+ if (groupPlan?.action === 'leave') {
5274
+ const left = conversationGroups.setMembership(conversationId, null, undefined, groupOptions);
5275
+ if (left.ok && left.changed) emitGroupChanged(left.previousGroupId, [conversationId]);
5276
+ } else if (groupPlan?.action === 'join') {
5277
+ const joined = conversationGroups.setMembership(conversationId, groupPlan.id, undefined, groupOptions);
5278
+ if (!joined.ok) {
5279
+ return failAfter(400, { code: 'SESSION_GROUP_NOT_FOUND', error: `The session group ${groupPlan.id} is gone.` });
5280
+ }
5281
+ if (joined.changed) {
5282
+ emitGroupChanged(groupPlan.id, [conversationId]);
5283
+ if (joined.previousGroupId && joined.previousGroupId !== groupPlan.id) emitGroupChanged(joined.previousGroupId, [conversationId]);
5284
+ }
5285
+ } else if (groupPlan?.action === 'create') {
5286
+ const previousGroupId = conversationGroups.groupOfConversation(conversationId)?.id || null;
5287
+ const group = conversationGroups.createGroup({ name: groupPlan.name, conversationIds: [conversationId], agentMade: remoteRequest.remote === true, ...groupOptions });
5288
+ groupCreated = group ? { id: group.id, name: group.name } : null;
5289
+ if (group) emitGroupChanged(group.id, [conversationId]);
5290
+ if (previousGroupId) emitGroupChanged(previousGroupId, [conversationId]);
5291
+ }
4809
5292
 
5293
+ const notes = [...plan.notes];
5294
+ if (cwdResult?.warning) notes.push(cwdResult.warning);
4810
5295
  return res.json({
4811
5296
  ok: true,
4812
- conversationId,
4813
- preferredRelayMode: persisted.preferredRelayMode,
4814
- preferredModel: persisted.preferredModel,
4815
- preferredReasoningEffort: persisted.preferredReasoningEffort,
4816
- autoCompactWindow: persisted.autoCompactWindow ?? null,
4817
- thinkingEnabled: persisted.thinkingEnabled ?? null,
4818
- thinkingDisplay: persisted.thinkingDisplay ?? null,
4819
- updatedAt: persisted.updatedAt,
4820
- created: persisted.created,
4821
- senderClientId,
5297
+ ...facts,
5298
+ settings: currentSessionSettings(conversationId),
5299
+ changed: plan.changed,
5300
+ ...(cwdResult ? { relaunched: cwdResult.relaunched } : {}),
5301
+ ...(groupCreated ? { groupCreated } : {}),
5302
+ ...(notes.length ? { notes } : {}),
4822
5303
  });
4823
5304
  });
4824
5305
 
@@ -5426,6 +5907,8 @@ export function registerSessionsRoutes(app, deps) {
5426
5907
  if (wasShared) io.emit('share_revoked', { conversationId: id });
5427
5908
  if (leftGroupId) emitGroupChanged(leftGroupId, [id]);
5428
5909
  try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
5910
+ try { relayScheduleService?.noteConversationGone?.(id, { deleted: true }); } catch {}
5911
+ try { relayAskService?.noteConversationGone?.(id); } catch {}
5429
5912
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
5430
5913
  }
5431
5914
 
@@ -5516,6 +5999,9 @@ export function registerSessionsRoutes(app, deps) {
5516
5999
  io.emit('conversation_archived', { conversationId: id, archived: true });
5517
6000
  // Its subscriptions end, and so does its place in others'.
5518
6001
  try { relayEventService?.noteConversationGone?.(id); } catch {}
6002
+ // So do its schedules.
6003
+ try { relayScheduleService?.noteConversationGone?.(id); } catch {}
6004
+ try { relayAskService?.noteConversationGone?.(id); } catch {}
5519
6005
  // The cloud session of a Claude Cloud conversation is archived with it;
5520
6006
  // best effort and not waited for.
5521
6007
  if (!sharedWithConversationId) {
@@ -5882,6 +6368,30 @@ export function registerSessionsRoutes(app, deps) {
5882
6368
  });
5883
6369
  }
5884
6370
 
6371
+ // The session group an agent's create_session names (an id or an exact
6372
+ // name): resolved before anything is written, so an unknown one creates
6373
+ // nothing. It wins over the creator's group. Only the row created here is
6374
+ // ever placed; the group routes stay the owner's.
6375
+ const rawGroup = req.body?.group;
6376
+ if (rawGroup !== undefined && rawGroup !== null && typeof rawGroup !== 'string') {
6377
+ return res.status(400).json({ ok: false, code: 'SESSION_GROUP_INVALID', error: 'group must be text: a group id or name' });
6378
+ }
6379
+ const requestedGroup = String(rawGroup || '').trim();
6380
+ let bootstrapGroupId = '';
6381
+ if (requestedGroup) {
6382
+ if (!conversationGroups?.enabled) {
6383
+ return res.status(503).json({
6384
+ ok: false,
6385
+ code: 'SESSION_GROUPS_UNAVAILABLE',
6386
+ error: 'Session groups are not available on this relay (its storage for them is missing). No conversation was created.',
6387
+ });
6388
+ }
6389
+ const groups = listGroupsForAgents();
6390
+ const found = resolveSessionGroupReference(groups, requestedGroup);
6391
+ if (found.status !== 'found') return res.status(400).json(bootstrapGroupRefusal(found, requestedGroup, groups));
6392
+ bootstrapGroupId = found.group.id;
6393
+ }
6394
+
5885
6395
  // Optional launch CWD from the New Chat modal. Validated before insertConv
5886
6396
  // so a rejected path never leaves an orphaned conversation behind.
5887
6397
  const requestedWorkspaceRootPath = readWorkspaceRootPathFromBody(req.body);
@@ -6000,14 +6510,23 @@ export function registerSessionsRoutes(app, deps) {
6000
6510
  // binding and preferences: every later reader treats these three as a
6001
6511
  // single canonical tuple.
6002
6512
  let attachedGroupId = null;
6513
+ let attachedGroupIds = [];
6003
6514
  const commitBootstrapState = db.transaction(() => {
6004
6515
  stmts.insertConv.run(conversationId, title, now, now);
6005
6516
  if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
6006
- // A session an agent of this relay created joins its creator's group
6007
- // (or starts one with the creator as head). A creator on a paired
6008
- // relay has no local flag and groups nothing.
6009
- if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
6517
+ if (bootstrapGroupId) {
6518
+ // The group named in the request. Dissolved since it was looked up:
6519
+ // the whole bootstrap rolls back, and the caller hears it as unknown.
6520
+ const placed = conversationGroups.setMembership(conversationId, bootstrapGroupId, now);
6521
+ if (!placed?.ok) throw Object.assign(new Error('session group gone'), { bootstrapGroupGone: true });
6522
+ attachedGroupId = bootstrapGroupId;
6523
+ attachedGroupIds = [conversationId];
6524
+ } else if (remoteRequest.origin?.local === true && remoteRequest.origin.conversationId) {
6525
+ // A session an agent of this relay created joins its creator's group
6526
+ // (or starts one with the creator as head). A creator on a paired
6527
+ // relay has no local flag and groups nothing.
6010
6528
  attachedGroupId = conversationGroups?.attachCreatedConversation?.(conversationId, remoteRequest.origin.conversationId, now) || null;
6529
+ attachedGroupIds = [conversationId, remoteRequest.origin.conversationId];
6011
6530
  }
6012
6531
  const boundRuntimeSession = ensureRuntimeSessionBinding(
6013
6532
  conversationId,
@@ -6037,7 +6556,7 @@ export function registerSessionsRoutes(app, deps) {
6037
6556
  return { boundRuntimeSession, preferences };
6038
6557
  });
6039
6558
  const { boundRuntimeSession, preferences } = commitBootstrapState();
6040
- if (attachedGroupId) emitGroupChanged(attachedGroupId, [conversationId, remoteRequest.origin.conversationId]);
6559
+ if (attachedGroupId) emitGroupChanged(attachedGroupId, attachedGroupIds);
6041
6560
  const runtimeSession = boundRuntimeSession;
6042
6561
  const preferredRelayMode = preferences?.preferredRelayMode || bootstrapRelayMode;
6043
6562
  const preferredModel = preferences?.preferredModel || selectedModel;
@@ -6138,6 +6657,9 @@ export function registerSessionsRoutes(app, deps) {
6138
6657
  cloud: bootstrapCloudSource
6139
6658
  ? buildConversationCloudPayload({ cloudSourceJson: bootstrapCloudSource })
6140
6659
  : null,
6660
+ // Always present (null outside a group): a caller that asked for a
6661
+ // group tells this relay from one that predates it by the key.
6662
+ group: conversationGroupField(conversationId),
6141
6663
  runtimeSessionId: runtimeSession?.id || null,
6142
6664
  ownerSessionId,
6143
6665
  worker: ownerWorker,
@@ -6156,6 +6678,10 @@ export function registerSessionsRoutes(app, deps) {
6156
6678
  ...workspaceRootPayload(),
6157
6679
  });
6158
6680
  } catch (error) {
6681
+ if (error?.bootstrapGroupGone) {
6682
+ const groups = listGroupsForAgents();
6683
+ return res.status(400).json(bootstrapGroupRefusal({ status: 'unknown' }, requestedGroup, groups));
6684
+ }
6159
6685
  console.warn(`[bootstrap] conversation bootstrap failed: ${error?.message || error}`);
6160
6686
  return res.status(500).json({
6161
6687
  ok: false,