@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.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -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/settings-modal.js +17 -3
- 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 +613 -56
- 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/oar-cli-helpers.mjs +12 -4
- 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/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- 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
- package/.github/extensions/web-relay/README.md +0 -190
- 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
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
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(
|
|
4816
|
+
const mentions = (key) => Object.prototype.hasOwnProperty.call(body || {}, key);
|
|
4757
4817
|
const preferredRelayMode = normalizeRelayModePreference(
|
|
4758
|
-
mentions('preferredRelayMode') ?
|
|
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') ?
|
|
4825
|
+
mentions('preferredModel') ? body.preferredModel : existing?.preferred_model,
|
|
4766
4826
|
);
|
|
4767
4827
|
const preferredReasoningEffort = normalizePreferredReasoningEffort(
|
|
4768
|
-
mentions('preferredReasoningEffort') ?
|
|
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
|
-
...(
|
|
4784
|
-
? { autoCompactWindow: parseAutoCompactWindow(
|
|
4840
|
+
...(mentions('autoCompactWindow')
|
|
4841
|
+
? { autoCompactWindow: parseAutoCompactWindow(body.autoCompactWindow) }
|
|
4785
4842
|
: {}),
|
|
4786
|
-
...(
|
|
4787
|
-
? { thinkingEnabled: parseThinkingEnabled(
|
|
4843
|
+
...(mentions('thinkingEnabled')
|
|
4844
|
+
? { thinkingEnabled: parseThinkingEnabled(body.thinkingEnabled) }
|
|
4788
4845
|
: {}),
|
|
4789
|
-
...(
|
|
4790
|
-
? { thinkingDisplay: parseThinkingDisplay(
|
|
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
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
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
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
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,
|
|
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
|
-
|
|
7347
|
-
|
|
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
|
|
7356
|
-
//
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
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) {
|