@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.
- package/CHANGELOG.md +85 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +568 -42
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- 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
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
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(
|
|
4816
|
+
const mentions = (key) => Object.prototype.hasOwnProperty.call(body || {}, key);
|
|
4758
4817
|
const preferredRelayMode = normalizeRelayModePreference(
|
|
4759
|
-
mentions('preferredRelayMode') ?
|
|
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') ?
|
|
4825
|
+
mentions('preferredModel') ? body.preferredModel : existing?.preferred_model,
|
|
4767
4826
|
);
|
|
4768
4827
|
const preferredReasoningEffort = normalizePreferredReasoningEffort(
|
|
4769
|
-
mentions('preferredReasoningEffort') ?
|
|
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
|
-
...(
|
|
4785
|
-
? { autoCompactWindow: parseAutoCompactWindow(
|
|
4840
|
+
...(mentions('autoCompactWindow')
|
|
4841
|
+
? { autoCompactWindow: parseAutoCompactWindow(body.autoCompactWindow) }
|
|
4786
4842
|
: {}),
|
|
4787
|
-
...(
|
|
4788
|
-
? { thinkingEnabled: parseThinkingEnabled(
|
|
4843
|
+
...(mentions('thinkingEnabled')
|
|
4844
|
+
? { thinkingEnabled: parseThinkingEnabled(body.thinkingEnabled) }
|
|
4789
4845
|
: {}),
|
|
4790
|
-
...(
|
|
4791
|
-
? { thinkingDisplay: parseThinkingDisplay(
|
|
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
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
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
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
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,
|
|
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,
|