@oar-sh/oar 0.9.14 → 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 (59) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +525 -12
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  6. package/server/claude-worker/claude-session-process.mjs +4 -4
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  8. package/server/db-schema.mjs +52 -0
  9. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  10. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  11. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  12. package/server/migrations/0022-relay-schedules.mjs +142 -0
  13. package/server/migrations/0023-relay-asks.mjs +141 -0
  14. package/server/public/app/ask-user-view.js +3 -0
  15. package/server/public/app/bootstrap.js +61 -3
  16. package/server/public/app/context-usage-view.mjs +17 -0
  17. package/server/public/app/conversation-group-order.mjs +35 -0
  18. package/server/public/app/inbox-ui.js +356 -0
  19. package/server/public/app/inbox-view.mjs +370 -0
  20. package/server/public/app/journal-view.js +23 -2
  21. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  22. package/server/public/app/relay-event-view.mjs +33 -7
  23. package/server/public/app/relay-schedule-view.mjs +119 -0
  24. package/server/public/app/relay-schedules-ui.js +172 -0
  25. package/server/public/app/settings-modal.js +17 -3
  26. package/server/public/app/socket-handlers.js +27 -3
  27. package/server/public/app/store.js +18 -5
  28. package/server/public/index.html +159 -2
  29. package/server/relay-tools.md +18 -3
  30. package/server/repositories/conversation-group-repository.mjs +97 -11
  31. package/server/repositories/remote-relay-repository.mjs +20 -2
  32. package/server/repositories/session-repository.mjs +7 -1
  33. package/server/routes/ask-user-routes.mjs +57 -3
  34. package/server/routes/cache-routes.mjs +6 -0
  35. package/server/routes/conversation-group-routes.mjs +17 -2
  36. package/server/routes/messages-routes.mjs +3 -3
  37. package/server/routes/relay-ask-routes.mjs +387 -0
  38. package/server/routes/relay-event-routes.mjs +3 -1
  39. package/server/routes/relay-schedule-routes.mjs +57 -0
  40. package/server/routes/sessions-routes.mjs +613 -56
  41. package/server/server-runtime.mjs +80 -1
  42. package/server/services/cache-rebuild-service.mjs +19 -2
  43. package/server/services/conversation-preferences-service.mjs +17 -0
  44. package/server/services/oar-cli-helpers.mjs +12 -4
  45. package/server/services/relay-ask-link-service.mjs +174 -0
  46. package/server/services/relay-ask-service.mjs +604 -0
  47. package/server/services/relay-event-service.mjs +589 -73
  48. package/server/services/relay-schedule-service.mjs +596 -0
  49. package/server/services/remote-relay-caller-context.mjs +89 -20
  50. package/server/services/remote-relay-dispatcher.mjs +956 -30
  51. package/server/services/windows-autostart-service.mjs +88 -16
  52. package/server/services/windows-boot-autostart-service.mjs +775 -126
  53. package/shared/relay-asks.mjs +145 -0
  54. package/shared/relay-events.mjs +268 -18
  55. package/shared/relay-schedules.mjs +128 -0
  56. package/shared/remote-relay-contract.mjs +553 -25
  57. package/shared/remote-relay-tool-core.mjs +16 -5
  58. package/.github/extensions/web-relay/README.md +0 -190
  59. package/server/README.md +0 -2334
@@ -9,6 +9,7 @@ import { randomUUID } from 'crypto';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
10
  import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
+ import { describeWindowsAutostart, handMadeLines, switchBlockers } from '../services/windows-boot-autostart-service.mjs';
12
13
  import {
13
14
  buildConversationHistoryPageInfo,
14
15
  normalizeConversationHistoryLimit,
@@ -16,7 +17,7 @@ import {
16
17
  } from '../services/conversation-history-page.mjs';
17
18
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
18
19
  import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
19
- import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
20
+ import { persistConversationPreferences, readMayCreateSessions } from '../services/conversation-preferences-service.mjs';
20
21
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
21
22
  import {
22
23
  claudeBackgroundCommandLimitSettingsFields,
@@ -60,7 +61,7 @@ import { requestHostOf } from '../services/request-boundary.mjs';
60
61
  import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
61
62
  import { createShareRepository } from '../repositories/share-repository.mjs';
62
63
  import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
63
- import { resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
64
+ import { GROUP_NAME_MAX, resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
64
65
  import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
65
66
  import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
66
67
  import {
@@ -81,6 +82,14 @@ import {
81
82
  } from '../../shared/model-id.mjs';
82
83
  import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mjs';
83
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';
84
93
  import {
85
94
  buildCloudSourceRecord,
86
95
  buildConversationCloudPayload,
@@ -2166,6 +2175,12 @@ export function registerSessionsRoutes(app, deps) {
2166
2175
  // Relay events: what each session is doing (state, tasks) and the
2167
2176
  // subscriptions that end with an archived or deleted conversation.
2168
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,
2169
2184
  sessionActivityTracker = null,
2170
2185
  // Public shares and their access log (migration 0016); built on `db` when
2171
2186
  // the wiring does not pass one.
@@ -2615,6 +2630,8 @@ export function registerSessionsRoutes(app, deps) {
2615
2630
  io.emit('conversation_deleted', { conversationId });
2616
2631
  if (leftGroupId) emitGroupChanged(leftGroupId, [conversationId]);
2617
2632
  try { relayEventService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2633
+ try { relayScheduleService?.noteConversationGone?.(conversationId, { deleted: true }); } catch {}
2634
+ try { relayAskService?.noteConversationGone?.(conversationId); } catch {}
2618
2635
  finalized.push(conversationId);
2619
2636
  }
2620
2637
  return finalized;
@@ -2640,6 +2657,8 @@ export function registerSessionsRoutes(app, deps) {
2640
2657
  parentConversationId: group.parentConversationId || null,
2641
2658
  memberCount: members.length,
2642
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,
2643
2662
  };
2644
2663
  }
2645
2664
 
@@ -2701,6 +2720,38 @@ export function registerSessionsRoutes(app, deps) {
2701
2720
  });
2702
2721
  }
2703
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
+
2704
2755
  /** The `cloud` field of a conversation payload; null unless it is a cloud one. */
2705
2756
  function conversationCloudPayload(conversation, runtimeSession) {
2706
2757
  return buildConversationCloudPayload({
@@ -3115,6 +3166,8 @@ export function registerSessionsRoutes(app, deps) {
3115
3166
  draftUpdatedByClientId: r.draft_updated_by_client_id || null,
3116
3167
  // Set on conversations another relay's agent created ("via <relay>").
3117
3168
  origin: parseRemoteRelayOriginJson(r.origin_json),
3169
+ // The owner let this agent-started session's agents start sessions.
3170
+ mayCreateSessions: readMayCreateSessions(r),
3118
3171
  // Set on Claude Cloud conversations: repository, branch, session link.
3119
3172
  cloud: buildConversationCloudPayload({
3120
3173
  cloudSourceJson: r.cloud_source_json,
@@ -3139,6 +3192,14 @@ export function registerSessionsRoutes(app, deps) {
3139
3192
  for (const groupId of new Set(page.rows.map((row) => String(row?.group_id || '')).filter((id) => groups.has(id)))) {
3140
3193
  groupsPayload[groupId] = groupSummaryPayload(groups.get(groupId));
3141
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
+ }
3142
3203
 
3143
3204
  return res.json({
3144
3205
  conversations,
@@ -3861,6 +3922,7 @@ export function registerSessionsRoutes(app, deps) {
3861
3922
  draftUpdatedAt: conv.draft_updated_at || null,
3862
3923
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3863
3924
  origin: parseRemoteRelayOriginJson(conv.origin_json),
3925
+ mayCreateSessions: readMayCreateSessions(conv),
3864
3926
  cloud: conversationCloudPayload(conv, runtimeSession),
3865
3927
  group: conversationGroupPayload(conversationId),
3866
3928
  groupPosition: conversationGroupPositionOf(conv),
@@ -4562,6 +4624,7 @@ export function registerSessionsRoutes(app, deps) {
4562
4624
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
4563
4625
  // Set on conversations another relay's agent created ("via <relay>").
4564
4626
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4627
+ mayCreateSessions: readMayCreateSessions(conv),
4565
4628
  // Set on Claude Cloud conversations: repository, branch, session link.
4566
4629
  cloud: conversationCloudPayload(conv, runtimeSession),
4567
4630
  // The session group it is in, with the other members; null when none.
@@ -4737,42 +4800,36 @@ export function registerSessionsRoutes(app, deps) {
4737
4800
  });
4738
4801
  });
4739
4802
 
4740
- app.patch('/api/conversation/:id/preferences', auth, (req, res) => {
4741
- const conversationId = String(req.params.id || '').trim();
4742
- if (!conversationId) return res.status(400).json({ error: 'Missing conversation id' });
4743
- const senderClientId = String(req.body?.clientId || '').trim() || null;
4744
-
4745
- const existing = stmts.getConvAnyStatus.get(conversationId);
4746
- if (existing && String(existing.status || '').trim() === 'deleted') {
4747
- return res.status(404).json({ error: 'Conversation not found' });
4748
- }
4749
-
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 }) {
4750
4810
  const now = new Date().toISOString();
4751
4811
  // Every write replaces all four columns, so a caller that only wants to
4752
4812
  // change one field (the context modal's auto-compact slider) must not have
4753
4813
  // the others reset to their defaults: an unmentioned key keeps the stored
4754
4814
  // value. The composer always sends all three, so its behaviour — including
4755
4815
  // clearing a preference with an explicit '' — is unchanged.
4756
- const mentions = (key) => Object.prototype.hasOwnProperty.call(req.body || {}, key);
4816
+ const mentions = (key) => Object.prototype.hasOwnProperty.call(body || {}, key);
4757
4817
  const preferredRelayMode = normalizeRelayModePreference(
4758
- mentions('preferredRelayMode') ? req.body?.preferredRelayMode : existing?.preferred_relay_mode,
4818
+ mentions('preferredRelayMode') ? body.preferredRelayMode : existing?.preferred_relay_mode,
4759
4819
  {
4760
4820
  supportedRelayModes: SUPPORTED_RELAY_MODES,
4761
4821
  fallbackMode: DEFAULT_RELAY_MODE,
4762
4822
  },
4763
4823
  );
4764
4824
  const preferredModel = normalizePreferredModel(
4765
- mentions('preferredModel') ? req.body?.preferredModel : existing?.preferred_model,
4825
+ mentions('preferredModel') ? body.preferredModel : existing?.preferred_model,
4766
4826
  );
4767
4827
  const preferredReasoningEffort = normalizePreferredReasoningEffort(
4768
- mentions('preferredReasoningEffort') ? req.body?.preferredReasoningEffort : existing?.preferred_reasoning_effort,
4828
+ mentions('preferredReasoningEffort') ? body.preferredReasoningEffort : existing?.preferred_reasoning_effort,
4769
4829
  );
4770
4830
  // Only an explicit mention rewrites the window (null = Auto is a real
4771
4831
  // value, so a plain falsy check would not do). Same for the thinking
4772
4832
  // control, where null = Host default.
4773
- const mentionsAutoCompactWindow = mentions('autoCompactWindow');
4774
- const mentionsThinkingEnabled = mentions('thinkingEnabled');
4775
- const mentionsThinkingDisplay = mentions('thinkingDisplay');
4776
4833
  const persisted = persistConversationPreferences({
4777
4834
  db,
4778
4835
  stmts,
@@ -4780,21 +4837,21 @@ export function registerSessionsRoutes(app, deps) {
4780
4837
  preferredRelayMode,
4781
4838
  preferredModel,
4782
4839
  preferredReasoningEffort,
4783
- ...(mentionsAutoCompactWindow
4784
- ? { autoCompactWindow: parseAutoCompactWindow(req.body?.autoCompactWindow) }
4840
+ ...(mentions('autoCompactWindow')
4841
+ ? { autoCompactWindow: parseAutoCompactWindow(body.autoCompactWindow) }
4785
4842
  : {}),
4786
- ...(mentionsThinkingEnabled
4787
- ? { thinkingEnabled: parseThinkingEnabled(req.body?.thinkingEnabled) }
4843
+ ...(mentions('thinkingEnabled')
4844
+ ? { thinkingEnabled: parseThinkingEnabled(body.thinkingEnabled) }
4788
4845
  : {}),
4789
- ...(mentionsThinkingDisplay
4790
- ? { thinkingDisplay: parseThinkingDisplay(req.body?.thinkingDisplay) }
4846
+ ...(mentions('thinkingDisplay')
4847
+ ? { thinkingDisplay: parseThinkingDisplay(body.thinkingDisplay) }
4791
4848
  : {}),
4849
+ ...(mentions('mayCreateSessions') ? { mayCreateSessions: body.mayCreateSessions === true } : {}),
4792
4850
  updatedAt: now,
4793
4851
  createIfMissing: !existing,
4794
4852
  createTitle: 'Session',
4795
4853
  });
4796
-
4797
- io.emit('conversation_preferences_updated', {
4854
+ const payload = {
4798
4855
  conversationId,
4799
4856
  preferredRelayMode: persisted.preferredRelayMode,
4800
4857
  preferredModel: persisted.preferredModel,
@@ -4802,22 +4859,447 @@ export function registerSessionsRoutes(app, deps) {
4802
4859
  autoCompactWindow: persisted.autoCompactWindow ?? null,
4803
4860
  thinkingEnabled: persisted.thinkingEnabled ?? null,
4804
4861
  thinkingDisplay: persisted.thinkingDisplay ?? null,
4862
+ mayCreateSessions: persisted.mayCreateSessions === true,
4805
4863
  updatedAt: persisted.updatedAt,
4806
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],
4807
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
+ }
4808
5292
 
5293
+ const notes = [...plan.notes];
5294
+ if (cwdResult?.warning) notes.push(cwdResult.warning);
4809
5295
  return res.json({
4810
5296
  ok: true,
4811
- conversationId,
4812
- preferredRelayMode: persisted.preferredRelayMode,
4813
- preferredModel: persisted.preferredModel,
4814
- preferredReasoningEffort: persisted.preferredReasoningEffort,
4815
- autoCompactWindow: persisted.autoCompactWindow ?? null,
4816
- thinkingEnabled: persisted.thinkingEnabled ?? null,
4817
- thinkingDisplay: persisted.thinkingDisplay ?? null,
4818
- updatedAt: persisted.updatedAt,
4819
- created: persisted.created,
4820
- senderClientId,
5297
+ ...facts,
5298
+ settings: currentSessionSettings(conversationId),
5299
+ changed: plan.changed,
5300
+ ...(cwdResult ? { relaunched: cwdResult.relaunched } : {}),
5301
+ ...(groupCreated ? { groupCreated } : {}),
5302
+ ...(notes.length ? { notes } : {}),
4821
5303
  });
4822
5304
  });
4823
5305
 
@@ -5425,6 +5907,8 @@ export function registerSessionsRoutes(app, deps) {
5425
5907
  if (wasShared) io.emit('share_revoked', { conversationId: id });
5426
5908
  if (leftGroupId) emitGroupChanged(leftGroupId, [id]);
5427
5909
  try { relayEventService?.noteConversationGone?.(id, { deleted: true }); } catch {}
5910
+ try { relayScheduleService?.noteConversationGone?.(id, { deleted: true }); } catch {}
5911
+ try { relayAskService?.noteConversationGone?.(id); } catch {}
5428
5912
  return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
5429
5913
  }
5430
5914
 
@@ -5515,6 +5999,9 @@ export function registerSessionsRoutes(app, deps) {
5515
5999
  io.emit('conversation_archived', { conversationId: id, archived: true });
5516
6000
  // Its subscriptions end, and so does its place in others'.
5517
6001
  try { relayEventService?.noteConversationGone?.(id); } catch {}
6002
+ // So do its schedules.
6003
+ try { relayScheduleService?.noteConversationGone?.(id); } catch {}
6004
+ try { relayAskService?.noteConversationGone?.(id); } catch {}
5518
6005
  // The cloud session of a Claude Cloud conversation is archived with it;
5519
6006
  // best effort and not waited for.
5520
6007
  if (!sharedWithConversationId) {
@@ -5881,6 +6368,30 @@ export function registerSessionsRoutes(app, deps) {
5881
6368
  });
5882
6369
  }
5883
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
+
5884
6395
  // Optional launch CWD from the New Chat modal. Validated before insertConv
5885
6396
  // so a rejected path never leaves an orphaned conversation behind.
5886
6397
  const requestedWorkspaceRootPath = readWorkspaceRootPathFromBody(req.body);
@@ -5999,14 +6510,23 @@ export function registerSessionsRoutes(app, deps) {
5999
6510
  // binding and preferences: every later reader treats these three as a
6000
6511
  // single canonical tuple.
6001
6512
  let attachedGroupId = null;
6513
+ let attachedGroupIds = [];
6002
6514
  const commitBootstrapState = db.transaction(() => {
6003
6515
  stmts.insertConv.run(conversationId, title, now, now);
6004
6516
  if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
6005
- // A session an agent of this relay created joins its creator's group
6006
- // (or starts one with the creator as head). A creator on a paired
6007
- // relay has no local flag and groups nothing.
6008
- 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.
6009
6528
  attachedGroupId = conversationGroups?.attachCreatedConversation?.(conversationId, remoteRequest.origin.conversationId, now) || null;
6529
+ attachedGroupIds = [conversationId, remoteRequest.origin.conversationId];
6010
6530
  }
6011
6531
  const boundRuntimeSession = ensureRuntimeSessionBinding(
6012
6532
  conversationId,
@@ -6036,7 +6556,7 @@ export function registerSessionsRoutes(app, deps) {
6036
6556
  return { boundRuntimeSession, preferences };
6037
6557
  });
6038
6558
  const { boundRuntimeSession, preferences } = commitBootstrapState();
6039
- if (attachedGroupId) emitGroupChanged(attachedGroupId, [conversationId, remoteRequest.origin.conversationId]);
6559
+ if (attachedGroupId) emitGroupChanged(attachedGroupId, attachedGroupIds);
6040
6560
  const runtimeSession = boundRuntimeSession;
6041
6561
  const preferredRelayMode = preferences?.preferredRelayMode || bootstrapRelayMode;
6042
6562
  const preferredModel = preferences?.preferredModel || selectedModel;
@@ -6137,6 +6657,9 @@ export function registerSessionsRoutes(app, deps) {
6137
6657
  cloud: bootstrapCloudSource
6138
6658
  ? buildConversationCloudPayload({ cloudSourceJson: bootstrapCloudSource })
6139
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),
6140
6663
  runtimeSessionId: runtimeSession?.id || null,
6141
6664
  ownerSessionId,
6142
6665
  worker: ownerWorker,
@@ -6155,6 +6678,10 @@ export function registerSessionsRoutes(app, deps) {
6155
6678
  ...workspaceRootPayload(),
6156
6679
  });
6157
6680
  } catch (error) {
6681
+ if (error?.bootstrapGroupGone) {
6682
+ const groups = listGroupsForAgents();
6683
+ return res.status(400).json(bootstrapGroupRefusal({ status: 'unknown' }, requestedGroup, groups));
6684
+ }
6158
6685
  console.warn(`[bootstrap] conversation bootstrap failed: ${error?.message || error}`);
6159
6686
  return res.status(500).json({
6160
6687
  ok: false,
@@ -7299,6 +7826,7 @@ export function registerSessionsRoutes(app, deps) {
7299
7826
  legacyTaskPresent: boot.legacyTaskPresent,
7300
7827
  customised: boot.customised === true,
7301
7828
  customisations: Array.isArray(boot.customisations) ? boot.customisations : [],
7829
+ handMade: Array.isArray(boot.handMade) ? boot.handMade : [],
7302
7830
  launcherBackupPath: boot.launcherBackupPath || null,
7303
7831
  readError: boot.readError || null,
7304
7832
  pendingElevation: boot.pendingElevation || null,
@@ -7306,6 +7834,9 @@ export function registerSessionsRoutes(app, deps) {
7306
7834
  manualCommand: boot.manualCommand || null,
7307
7835
  } : null,
7308
7836
  signinEnabled: signin.enabled,
7837
+ // The Startup entries OAR did not make, which it leaves as they are.
7838
+ signinHandMade: (windowsAutostartService.listEntries?.() || []).filter((entry) => !entry.generated)
7839
+ .map(({ path: entryPath, otherInstall }) => ({ path: entryPath, otherInstall: otherInstall || null })),
7309
7840
  };
7310
7841
  }
7311
7842
 
@@ -7342,9 +7873,31 @@ export function registerSessionsRoutes(app, deps) {
7342
7873
  if (!windowsAutostartService.getState().supported) {
7343
7874
  return res.status(400).json({ error: 'Windows autostart is only available on Windows' });
7344
7875
  }
7876
+ if (mode === 'boot' && !windowsBootAutostartService) {
7877
+ return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
7878
+ }
7879
+ // The rules of `oar autostart`: only entries OAR generated are deleted
7880
+ // or written over. A hand-made one (a Startup entry, our task or its
7881
+ // launcher changed by hand, a task someone else made) that would stay
7882
+ // beside the new mode, or be replaced by it, stops the switch with the
7883
+ // same message; replacing it is `oar autostart <mode> --replace`.
7884
+ const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
7885
+ const now = describeWindowsAutostart({
7886
+ signinEntries: windowsAutostartService.listEntries?.() || [],
7887
+ boot: boot?.supported ? boot : {},
7888
+ });
7889
+ if (now.mode === 'unknown') {
7890
+ return res.status(409).json({ error: 'The boot task could not be read, so nothing was changed; the relay log has the details.' });
7891
+ }
7892
+ const blocking = switchBlockers(now, mode);
7893
+ if (blocking.length) return res.status(409).json({ error: handMadeLines(blocking, mode).join(' ') });
7345
7894
  if (mode === 'boot') {
7346
- if (!windowsBootAutostartService) {
7347
- return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
7895
+ // A generated launcher that is missing or of an earlier version is
7896
+ // regenerated by enabling again; a hand-made one stays.
7897
+ const regenerate = boot.customised && !boot.handMade?.length;
7898
+ if (now.mode === 'boot' && !regenerate) {
7899
+ windowsAutostartService.setEnabled(false);
7900
+ return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
7348
7901
  }
7349
7902
  // The sweep's completion also removes the Startup .cmd entries; the
7350
7903
  // sign-in service is not touched here so a declined UAC leaves the
@@ -7352,19 +7905,23 @@ export function registerSessionsRoutes(app, deps) {
7352
7905
  const result = await windowsBootAutostartService.requestEnable();
7353
7906
  return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
7354
7907
  }
7355
- // Leaving boot mode needs its own elevated sweep before the sign-in
7356
- // half applies; a declined UAC must not leave both modes armed.
7357
- const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
7358
- let accepted = true;
7359
- if (boot?.supported && (boot.taskStatus === 'ready' || boot.taskStatus === 'foreign' || boot.legacyTaskPresent)) {
7360
- if (boot.taskStatus === 'foreign' && mode === 'signin') {
7361
- // Someone else's task under our name: never delete it as a side
7362
- // effect of picking sign-in mode.
7363
- windowsAutostartService.setEnabled(true);
7364
- return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
7365
- }
7366
- accepted = (await windowsBootAutostartService.requestDisable()).accepted;
7908
+ // Leaving boot mode needs its own elevated sweep; the sign-in half
7909
+ // applies once the prompt was confirmed and the task is gone, so a
7910
+ // declined or failed one leaves the earlier mode as it was.
7911
+ if (now.mode === 'boot') {
7912
+ const result = await windowsBootAutostartService.requestDisable({
7913
+ onSettled: (ok) => {
7914
+ if (!ok) return;
7915
+ try {
7916
+ windowsAutostartService.setEnabled(mode === 'signin');
7917
+ } catch (error) {
7918
+ console.warn(`[autostart] the boot task is gone, but the sign-in entry could not be changed: ${error?.message || error}`);
7919
+ }
7920
+ },
7921
+ });
7922
+ return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
7367
7923
  }
7924
+ const accepted = true;
7368
7925
  windowsAutostartService.setEnabled(mode === 'signin');
7369
7926
  return res.json({ ...(await windowsAutostartMergedState()), accepted });
7370
7927
  } catch (error) {