@oar-sh/oar 0.9.6 → 0.9.8
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 +285 -0
- package/README.md +222 -40
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
- package/server/claude-worker/claude-session-process.mjs +225 -24
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +149 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +468 -88
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +145 -11
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +37 -3
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +53 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +564 -20
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +123 -16
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +517 -74
- package/server/server-runtime.mjs +268 -33
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/update-install-service.mjs +9 -3
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -0
|
@@ -5,9 +5,12 @@ import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
|
5
5
|
import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
|
|
6
6
|
import {
|
|
7
7
|
REMOTE_RELAY_APPROVAL_MODES,
|
|
8
|
+
REMOTE_RELAY_CLOUD_PROVIDER,
|
|
8
9
|
REMOTE_RELAY_EFFORT_PATTERN,
|
|
9
10
|
REMOTE_RELAY_ERROR_CODES,
|
|
10
11
|
REMOTE_RELAY_LIMITS,
|
|
12
|
+
REMOTE_RELAY_LOCAL_ALIAS,
|
|
13
|
+
REMOTE_RELAY_LOCAL_ID,
|
|
11
14
|
REMOTE_RELAY_PROVIDERS,
|
|
12
15
|
isRemoteRelayApprovalQuestion,
|
|
13
16
|
isRemoteRelayPromptAction,
|
|
@@ -32,6 +35,19 @@ import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
|
32
35
|
// → permission → hop limit → rate limit → approval (write actions in
|
|
33
36
|
// ask/plan) → execute against the remote relay's existing API
|
|
34
37
|
//
|
|
38
|
+
// The relay the agent runs on is a target too (the "local target", named by
|
|
39
|
+
// this relay's name or `this`), when its owner switched that on. It needs no
|
|
40
|
+
// pairing, mention, permission or hop; instead:
|
|
41
|
+
//
|
|
42
|
+
// setting on → no acting on the caller's own conversation → rate limit →
|
|
43
|
+
// create_session only: one level (a created session creates none), at most
|
|
44
|
+
// localActiveSessions created sessions at work, the user's Allow once per
|
|
45
|
+
// conversation → approval (ask/plan) → execute
|
|
46
|
+
//
|
|
47
|
+
// Its actions run through the same code as a paired relay's: `call()` hands
|
|
48
|
+
// them to the loopback client, which reaches this relay's own routes
|
|
49
|
+
// in-process (remote-relay-loopback.mjs).
|
|
50
|
+
//
|
|
35
51
|
// The remote is reached only through the outbound client (auth, origin and
|
|
36
52
|
// hop headers are its job). Reply detection reads the remote's own transcript:
|
|
37
53
|
// the assistant row whose sourceMessageId is the message we queued, with the
|
|
@@ -73,7 +89,16 @@ const TITLE_MAX = 80;
|
|
|
73
89
|
|
|
74
90
|
// The provider settings routes a relay serves; github (Copilot) has none with
|
|
75
91
|
// models — its catalogue is /api/models plus /api/status defaultModel.
|
|
76
|
-
const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai']);
|
|
92
|
+
const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai', 'claude-cloud']);
|
|
93
|
+
// A Claude Cloud session clones a GitHub repository instead of opening a
|
|
94
|
+
// folder: create_session takes `repo` and `branch` for it.
|
|
95
|
+
const CLOUD_PROVIDER = REMOTE_RELAY_CLOUD_PROVIDER;
|
|
96
|
+
const CLOUD_CREATE_NOTE = 'create_session needs repo (https://github.com/owner/repo or owner/repo) and takes branch instead of cwd. The session works on a fresh clone in a sandbox at Anthropic: write a self-contained prompt and name the branch to work on and to push.';
|
|
97
|
+
// POST /api/conversation/bootstrap refuses a cloud session with this code
|
|
98
|
+
// while the provider is switched off.
|
|
99
|
+
const CLOUD_DISABLED_CODE = 'claude_cloud_disabled';
|
|
100
|
+
// The actions an agent must not aim at the conversation it runs in.
|
|
101
|
+
const OWN_SESSION_ACTIONS = new Set(['send', 'stop', 'archive', 'wait']);
|
|
77
102
|
// How /api/models `providersByModel` names each provider.
|
|
78
103
|
const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
79
104
|
github: 'github-copilot',
|
|
@@ -81,6 +106,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
|
81
106
|
claude: 'claude',
|
|
82
107
|
cursor: 'cursor',
|
|
83
108
|
grok: 'grok',
|
|
109
|
+
'claude-cloud': 'claude-cloud',
|
|
84
110
|
});
|
|
85
111
|
// Worker statuses that mean "working on something right now" (scope active).
|
|
86
112
|
const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
@@ -341,7 +367,7 @@ const PERMISSION_DESCRIPTIONS = Object.freeze({
|
|
|
341
367
|
});
|
|
342
368
|
|
|
343
369
|
function relayTarget(relay) {
|
|
344
|
-
return { id: relay.id, name: relay.name, url: relay.url };
|
|
370
|
+
return { id: relay.id, name: relay.name, url: relay.url, ...(relay.local ? { local: true } : {}) };
|
|
345
371
|
}
|
|
346
372
|
|
|
347
373
|
/**
|
|
@@ -518,6 +544,12 @@ export function settleRemoteReply(messages, messageId) {
|
|
|
518
544
|
export function createRemoteRelayDispatcher({
|
|
519
545
|
registry,
|
|
520
546
|
client,
|
|
547
|
+
// The local target's client (remote-relay-loopback.mjs): same `request` as
|
|
548
|
+
// `client`, answered by this relay's own routes. Without it the local target
|
|
549
|
+
// does not exist, whatever the setting says.
|
|
550
|
+
localClient = null,
|
|
551
|
+
// `{ enabled, maxWaitSeconds }`: the agent-sessions settings of this relay.
|
|
552
|
+
getAgentSessionsSettings = () => null,
|
|
521
553
|
repository,
|
|
522
554
|
getCallerContext = async () => null,
|
|
523
555
|
requestApproval = async () => false,
|
|
@@ -539,6 +571,13 @@ export function createRemoteRelayDispatcher({
|
|
|
539
571
|
// counted from the user's latest own message. Kept in memory: a restart
|
|
540
572
|
// ends a loop of agents by itself.
|
|
541
573
|
const agentPromptsByConversation = new Map();
|
|
574
|
+
// create_session calls on the local target that passed the cap and have not
|
|
575
|
+
// queued their prompt yet: they count toward it, so parallel calls cannot
|
|
576
|
+
// slip past together.
|
|
577
|
+
const localCreatesByConversation = new Map();
|
|
578
|
+
// The once-per-conversation approval card while it is open: parallel first
|
|
579
|
+
// creates share the one card and its answer.
|
|
580
|
+
const localApprovalsByConversation = new Map();
|
|
542
581
|
|
|
543
582
|
// ─── Bookkeeping ───────────────────────────────────────────────────────────
|
|
544
583
|
|
|
@@ -586,6 +625,41 @@ export function createRemoteRelayDispatcher({
|
|
|
586
625
|
return fresh;
|
|
587
626
|
}
|
|
588
627
|
|
|
628
|
+
/** `{ enabled, maxWaitSeconds }`; off and the default wait when unreadable. */
|
|
629
|
+
function agentSessions() {
|
|
630
|
+
let settings = null;
|
|
631
|
+
try {
|
|
632
|
+
settings = getAgentSessionsSettings() || null;
|
|
633
|
+
} catch {}
|
|
634
|
+
const maxWaitSeconds = Number(settings?.maxWaitSeconds);
|
|
635
|
+
return {
|
|
636
|
+
enabled: settings?.enabled === true && typeof localClient?.request === 'function',
|
|
637
|
+
maxWaitSeconds: Number.isFinite(maxWaitSeconds) && maxWaitSeconds > 0
|
|
638
|
+
? maxWaitSeconds
|
|
639
|
+
: REMOTE_RELAY_LIMITS.waitMaxSeconds,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** This relay as a target: the shape of a registry entry, marked `local`. */
|
|
644
|
+
async function localTarget() {
|
|
645
|
+
const self = (await registry.selfIdentity?.()) || {};
|
|
646
|
+
return {
|
|
647
|
+
id: REMOTE_RELAY_LOCAL_ID,
|
|
648
|
+
relayId: toText(self.relayId) || null,
|
|
649
|
+
name: toText(self.name) || toText(await registry.selfName?.()) || REMOTE_RELAY_LOCAL_ALIAS,
|
|
650
|
+
url: toText(self.publicUrl),
|
|
651
|
+
version: toText(self.version) || null,
|
|
652
|
+
permission: 'full',
|
|
653
|
+
local: true,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function adjustLocalCreates(conversationId, delta) {
|
|
658
|
+
const next = (localCreatesByConversation.get(conversationId) || 0) + delta;
|
|
659
|
+
if (next > 0) localCreatesByConversation.set(conversationId, next);
|
|
660
|
+
else localCreatesByConversation.delete(conversationId);
|
|
661
|
+
}
|
|
662
|
+
|
|
589
663
|
function adjustInflight(conversationId, delta) {
|
|
590
664
|
const next = inflight(conversationId) + delta;
|
|
591
665
|
if (next > 0) inflightByConversation.set(conversationId, next);
|
|
@@ -638,7 +712,7 @@ export function createRemoteRelayDispatcher({
|
|
|
638
712
|
// ─── Remote calls ──────────────────────────────────────────────────────────
|
|
639
713
|
|
|
640
714
|
function call(ctx, method, path, { query, body, timeoutMs } = {}) {
|
|
641
|
-
return client.request(ctx.relay, method, path, {
|
|
715
|
+
return (ctx.relay.local ? localClient : client).request(ctx.relay, method, path, {
|
|
642
716
|
...(query ? { query: compactQuery(query) } : {}),
|
|
643
717
|
...(body !== undefined ? { body } : {}),
|
|
644
718
|
...(timeoutMs ? { timeoutMs } : {}),
|
|
@@ -696,6 +770,8 @@ export function createRemoteRelayDispatcher({
|
|
|
696
770
|
provider: ctx.caller.provider,
|
|
697
771
|
model: ctx.caller.model,
|
|
698
772
|
hops: ctx.hops,
|
|
773
|
+
// The conversation named above lives on the relay that reads this.
|
|
774
|
+
...(ctx.relay.local ? { local: true } : {}),
|
|
699
775
|
});
|
|
700
776
|
}
|
|
701
777
|
|
|
@@ -908,6 +984,9 @@ export function createRemoteRelayDispatcher({
|
|
|
908
984
|
*/
|
|
909
985
|
function buildProviders(catalog) {
|
|
910
986
|
return listProviders(catalog).map((entry) => {
|
|
987
|
+
// A cloud session takes no effort; without this its models would be
|
|
988
|
+
// listed with the Claude provider's efforts (the ids are the same).
|
|
989
|
+
if (entry.provider === CLOUD_PROVIDER) return entry;
|
|
911
990
|
const efforts = groupEffortsByModel(catalog.models, entry.provider, entry.models);
|
|
912
991
|
return efforts.length ? { ...entry, efforts } : entry;
|
|
913
992
|
});
|
|
@@ -939,6 +1018,21 @@ export function createRemoteRelayDispatcher({
|
|
|
939
1018
|
}
|
|
940
1019
|
const settings = catalog.settings[provider];
|
|
941
1020
|
if (!settings) return { provider, configured: false, defaultModel: null, models: [] };
|
|
1021
|
+
if (provider === CLOUD_PROVIDER) {
|
|
1022
|
+
// Its settings route has its own shape: a switch and a default model.
|
|
1023
|
+
// A relay that predates the provider answers 404 and lands above.
|
|
1024
|
+
const enabled = settings.enabled === true;
|
|
1025
|
+
const cloudDefault = toText(settings.defaultModel) || null;
|
|
1026
|
+
return {
|
|
1027
|
+
provider,
|
|
1028
|
+
configured: enabled,
|
|
1029
|
+
defaultModel: cloudDefault,
|
|
1030
|
+
models: enabled
|
|
1031
|
+
? uniqueTexts([...(cloudDefault ? [cloudDefault] : []), ...(Array.isArray(settings.models) ? settings.models : [])])
|
|
1032
|
+
: [],
|
|
1033
|
+
...(enabled ? { note: CLOUD_CREATE_NOTE } : {}),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
942
1036
|
// What POST /api/conversation/bootstrap requires, plus "not switched off".
|
|
943
1037
|
const configured = settings.enabled === true
|
|
944
1038
|
&& (provider === 'claude' || provider === 'grok' || settings.configured === true);
|
|
@@ -970,12 +1064,18 @@ export function createRemoteRelayDispatcher({
|
|
|
970
1064
|
return {
|
|
971
1065
|
summary: `relay_info → ${ctx.relay.name}: OAR ${version || '?'}, providers ${configured.join(', ') || 'none'}`,
|
|
972
1066
|
name: toText(identity?.name) || ctx.relay.name,
|
|
1067
|
+
...(ctx.relay.local ? { self: true } : {}),
|
|
973
1068
|
url: ctx.relay.url,
|
|
974
|
-
openUrl: remoteConversationUrl(ctx.relay.url, ''),
|
|
1069
|
+
openUrl: remoteConversationUrl(ctx.relay.url, '') || null,
|
|
975
1070
|
version,
|
|
976
1071
|
platform: toText(status.platform) || toText(identity?.platform) || null,
|
|
977
1072
|
protocol: remoteRelays ? nonNegativeInt(remoteRelays.protocol) : 0,
|
|
978
|
-
|
|
1073
|
+
// The inbound switch is about other relays' agents; this relay's own are
|
|
1074
|
+
// let in by the agent-sessions setting, which is on or this would not run.
|
|
1075
|
+
acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
|
|
1076
|
+
...(ctx.relay.local ? {
|
|
1077
|
+
sessionRules: `create_session: at most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time; a session an agent created cannot create sessions; sessions run on this relay's accounts.`,
|
|
1078
|
+
} : {}),
|
|
979
1079
|
providers,
|
|
980
1080
|
workspaces: {
|
|
981
1081
|
default: toText(status.defaultSessionWorkspaceRootPath) || toText(status.workspaceRootPath) || null,
|
|
@@ -1000,9 +1100,11 @@ export function createRemoteRelayDispatcher({
|
|
|
1000
1100
|
}
|
|
1001
1101
|
}
|
|
1002
1102
|
|
|
1003
|
-
function mapSession(row, workerIds) {
|
|
1103
|
+
function mapSession(row, workerIds, ctx = null) {
|
|
1004
1104
|
const id = toText(row?.id);
|
|
1005
1105
|
const via = toText(row?.origin?.relayName);
|
|
1106
|
+
// A session an agent of that relay created there, and from which session.
|
|
1107
|
+
const createdBy = row?.origin?.local === true ? toText(row.origin.conversationId) : '';
|
|
1006
1108
|
return {
|
|
1007
1109
|
id,
|
|
1008
1110
|
title: toText(row?.title),
|
|
@@ -1013,6 +1115,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1013
1115
|
messageCount: nonNegativeInt(row?.messageCount),
|
|
1014
1116
|
cwd: toText(row?.currentWorkspaceRootPath) || null,
|
|
1015
1117
|
...(via ? { via } : {}),
|
|
1118
|
+
...(createdBy ? { createdBy } : {}),
|
|
1119
|
+
...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
|
|
1016
1120
|
};
|
|
1017
1121
|
}
|
|
1018
1122
|
|
|
@@ -1046,7 +1150,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1046
1150
|
let consumed = 0;
|
|
1047
1151
|
for (const row of rows) {
|
|
1048
1152
|
consumed += 1;
|
|
1049
|
-
if (keep(row)) sessions.push(mapSession(row, workerIds));
|
|
1153
|
+
if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
|
|
1050
1154
|
if (sessions.length >= limit) break;
|
|
1051
1155
|
}
|
|
1052
1156
|
scanned += consumed;
|
|
@@ -1163,7 +1267,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1163
1267
|
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1164
1268
|
archived: conversation?.archived === true,
|
|
1165
1269
|
...(via ? { via } : {}),
|
|
1166
|
-
openUrl: remoteConversationUrl(ctx.relay.url, conversationId),
|
|
1270
|
+
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1167
1271
|
},
|
|
1168
1272
|
messages,
|
|
1169
1273
|
...(inFlight ? { inFlight } : {}),
|
|
@@ -1294,7 +1398,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1294
1398
|
}
|
|
1295
1399
|
|
|
1296
1400
|
async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra: given = {} }) {
|
|
1297
|
-
|
|
1401
|
+
// Null for this relay itself while it has no public URL: no link to offer.
|
|
1402
|
+
const openUrl = remoteConversationUrl(ctx.relay.url, session) || null;
|
|
1298
1403
|
// `extra.note` (how the effort was settled) goes in front of the outcome's own.
|
|
1299
1404
|
const { note: extraNote, ...extra } = given;
|
|
1300
1405
|
const result = await deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl });
|
|
@@ -1354,22 +1459,27 @@ export function createRemoteRelayDispatcher({
|
|
|
1354
1459
|
const catalog = await loadCatalog(ctx);
|
|
1355
1460
|
const providers = buildProviders(catalog);
|
|
1356
1461
|
const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
|
|
1357
|
-
const provider =
|
|
1462
|
+
const provider = createProvider(ctx.caller, args);
|
|
1463
|
+
const cloud = provider === CLOUD_PROVIDER;
|
|
1358
1464
|
const entry = providers.find((candidate) => candidate.provider === provider);
|
|
1359
1465
|
if (!entry?.configured) {
|
|
1360
1466
|
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
|
|
1361
1467
|
`Relay "${ctx.relay.name}" has no ${provider} provider set up. Providers there: ${configured.join(', ') || 'none'}. Pass provider to pick one.`,
|
|
1362
|
-
{ providers: configured });
|
|
1468
|
+
{ providers: configured, ...(cloud && catalog.settings[CLOUD_PROVIDER] ? { remoteCode: CLOUD_DISABLED_CODE } : {}) });
|
|
1363
1469
|
}
|
|
1364
1470
|
|
|
1365
1471
|
let model = args.model || '';
|
|
1366
1472
|
let modelSource = model ? 'requested' : '';
|
|
1367
1473
|
let note = null;
|
|
1368
|
-
|
|
1474
|
+
// A cloud session without a requested model gets the Claude Cloud tab's
|
|
1475
|
+
// default, as New Chat does: the bootstrap picks it, nothing is mirrored.
|
|
1476
|
+
if (!model && cloud) {
|
|
1477
|
+
modelSource = 'remote default';
|
|
1478
|
+
} else if (!model) {
|
|
1369
1479
|
model = matchOfferedModel(ctx.caller.model, entry.models);
|
|
1370
1480
|
modelSource = model ? 'same as this session' : '';
|
|
1371
1481
|
}
|
|
1372
|
-
if (!model) {
|
|
1482
|
+
if (!model && !cloud) {
|
|
1373
1483
|
model = entry.defaultModel || entry.models[0] || '';
|
|
1374
1484
|
modelSource = 'remote default';
|
|
1375
1485
|
if (toText(ctx.caller.model)) {
|
|
@@ -1383,7 +1493,10 @@ export function createRemoteRelayDispatcher({
|
|
|
1383
1493
|
// The effort mirrors the caller like the model does: this session's when
|
|
1384
1494
|
// the remote lists it for the model (or lists nothing), else the remote's.
|
|
1385
1495
|
let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
|
|
1386
|
-
if (!choice.effort) {
|
|
1496
|
+
if (!choice.effort && cloud) {
|
|
1497
|
+
// A cloud session takes no effort: none is mirrored, none is reported.
|
|
1498
|
+
choice = { effort: '', source: '', note: null };
|
|
1499
|
+
} else if (!choice.effort) {
|
|
1387
1500
|
const callerEffort = normalizeEffort(ctx.caller.effort);
|
|
1388
1501
|
const offered = catalogEffortsFor(catalog.models, provider, model);
|
|
1389
1502
|
if (callerEffort && (!offered || offered.includes(callerEffort))) {
|
|
@@ -1407,6 +1520,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1407
1520
|
...(effort ? { reasoningEffort: effort } : {}),
|
|
1408
1521
|
title,
|
|
1409
1522
|
...(args.cwd ? { cwd: args.cwd } : {}),
|
|
1523
|
+
// What New Chat sends for a Claude Cloud conversation; the bootstrap
|
|
1524
|
+
// validates it and refuses with its own codes.
|
|
1525
|
+
...(cloud ? { cloudSource: { repoUrl: args.repo, ...(args.branch ? { branch: args.branch } : {}) } } : {}),
|
|
1410
1526
|
origin,
|
|
1411
1527
|
},
|
|
1412
1528
|
});
|
|
@@ -1424,7 +1540,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1424
1540
|
};
|
|
1425
1541
|
bootstrap = await bootstrapWith('');
|
|
1426
1542
|
}
|
|
1427
|
-
|
|
1543
|
+
// Nothing to settle for a cloud session that was asked for no effort.
|
|
1544
|
+
if (!cloud || choice.effort) choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
|
|
1428
1545
|
const session = toText(bootstrap?.conversationId);
|
|
1429
1546
|
if (!session) {
|
|
1430
1547
|
throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
|
|
@@ -1433,7 +1550,11 @@ export function createRemoteRelayDispatcher({
|
|
|
1433
1550
|
const boundModel = toText(bootstrap?.selectedModel) || model;
|
|
1434
1551
|
let sent;
|
|
1435
1552
|
try {
|
|
1436
|
-
|
|
1553
|
+
const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
|
|
1554
|
+
sent = posted.sent;
|
|
1555
|
+
if (!cloud || choice.effort) choice = posted.choice;
|
|
1556
|
+
// From here the new session's queued turn counts toward the cap itself.
|
|
1557
|
+
ctx.onPromptQueued?.();
|
|
1437
1558
|
} catch (error) {
|
|
1438
1559
|
if (!choice.effort || !isEffortRejection(error)) throw error;
|
|
1439
1560
|
// The session exists by now: say so, or the agent starts a second one.
|
|
@@ -1445,7 +1566,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1445
1566
|
session,
|
|
1446
1567
|
...(remoteCode ? { remoteCode } : {}),
|
|
1447
1568
|
...(supported.length ? { supportedEfforts: supported } : {}),
|
|
1448
|
-
openUrl: remoteConversationUrl(ctx.relay.url, session),
|
|
1569
|
+
openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
|
|
1449
1570
|
note: 'Use send with this session and an effort it takes (or none); do not create another session.',
|
|
1450
1571
|
});
|
|
1451
1572
|
}
|
|
@@ -1459,6 +1580,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1459
1580
|
modelSource,
|
|
1460
1581
|
mode: toText(bootstrap?.preferredRelayMode) || mode || null,
|
|
1461
1582
|
title,
|
|
1583
|
+
...(cloud ? { repo: toText(bootstrap?.cloud?.repoUrl) || args.repo, branch: toText(bootstrap?.cloud?.branch) || args.branch || null } : {}),
|
|
1462
1584
|
...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
|
|
1463
1585
|
...(note ? { modelNote: note } : {}),
|
|
1464
1586
|
...effortResult(choice),
|
|
@@ -1466,6 +1588,37 @@ export function createRemoteRelayDispatcher({
|
|
|
1466
1588
|
});
|
|
1467
1589
|
}
|
|
1468
1590
|
|
|
1591
|
+
/** The provider a new session gets: the one asked for, else the calling session's. */
|
|
1592
|
+
function createProvider(caller, args) {
|
|
1593
|
+
return args.provider || normalizeProvider(caller?.provider) || 'github';
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* What is wrong with a create_session call that only the calling session
|
|
1598
|
+
* reveals, or null. Checked before anyone is asked for approval: a card for
|
|
1599
|
+
* a call that cannot run would be a question about nothing.
|
|
1600
|
+
*/
|
|
1601
|
+
function createSessionProblem(caller, args) {
|
|
1602
|
+
if (createProvider(caller, args) === CLOUD_PROVIDER && !args.repo) {
|
|
1603
|
+
// The provider came from the calling session (the validator requires
|
|
1604
|
+
// repo when the call names claude-cloud itself).
|
|
1605
|
+
return `This session's provider is ${CLOUD_PROVIDER}, which a new session gets by default, and a Claude Cloud session needs repo (https://github.com/owner/repo or owner/repo). Pass repo (and branch), or pass another provider (relay_info lists them).`;
|
|
1606
|
+
}
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/** Whether `session` (a conversation or session id on this relay) is the caller's own conversation. */
|
|
1611
|
+
function isOwnConversation(ctx, session) {
|
|
1612
|
+
const id = toText(session);
|
|
1613
|
+
if (!id) return false;
|
|
1614
|
+
if (id === ctx.conversationId) return true;
|
|
1615
|
+
try {
|
|
1616
|
+
return toText(resolveConversationId(id)) === ctx.conversationId;
|
|
1617
|
+
} catch {
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1469
1622
|
/** The answer body the remote's answer route expects for this question. */
|
|
1470
1623
|
function buildAnswer(question, args) {
|
|
1471
1624
|
const fields = schemaFields(question.requestSchema);
|
|
@@ -1504,6 +1657,11 @@ export function createRemoteRelayDispatcher({
|
|
|
1504
1657
|
`Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
|
|
1505
1658
|
}
|
|
1506
1659
|
const session = toText(question.conversationId) || null;
|
|
1660
|
+
if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
|
|
1661
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
|
|
1662
|
+
`Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
|
|
1663
|
+
{ session });
|
|
1664
|
+
}
|
|
1507
1665
|
if (isRemoteRelayApprovalQuestion(question)) {
|
|
1508
1666
|
throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
1509
1667
|
`Question ${args.question_id} is relay "${ctx.relay.name}"'s approval of its own agent's call to another relay; only its user can answer it.`,
|
|
@@ -1618,6 +1776,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1618
1776
|
|
|
1619
1777
|
async function listRelays(conversationId) {
|
|
1620
1778
|
const relays = (await registry.list()) || [];
|
|
1779
|
+
const settings = agentSessions();
|
|
1780
|
+
const local = settings.enabled ? await localTarget() : null;
|
|
1621
1781
|
const listed = relays.map((relay) => ({
|
|
1622
1782
|
name: relay.name,
|
|
1623
1783
|
url: relay.url,
|
|
@@ -1626,6 +1786,10 @@ export function createRemoteRelayDispatcher({
|
|
|
1626
1786
|
permission: relay.permission,
|
|
1627
1787
|
unlocked: relayAccess(conversationId, relay) !== null,
|
|
1628
1788
|
}));
|
|
1789
|
+
// This relay itself comes first: no address, always online, never locked.
|
|
1790
|
+
if (local) {
|
|
1791
|
+
listed.unshift({ name: local.name, self: true, online: true, version: local.version, permission: 'full', unlocked: true });
|
|
1792
|
+
}
|
|
1629
1793
|
const unlocked = listed.filter((relay) => relay.unlocked).length;
|
|
1630
1794
|
return {
|
|
1631
1795
|
status: 200,
|
|
@@ -1634,21 +1798,155 @@ export function createRemoteRelayDispatcher({
|
|
|
1634
1798
|
summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
|
|
1635
1799
|
self: { name: (await registry.selfName?.()) || null },
|
|
1636
1800
|
relays: listed,
|
|
1637
|
-
|
|
1801
|
+
limits: {
|
|
1802
|
+
maxWaitSeconds: settings.maxWaitSeconds,
|
|
1803
|
+
...(local ? { maxActiveSessions: REMOTE_RELAY_LIMITS.localActiveSessions } : {}),
|
|
1804
|
+
},
|
|
1805
|
+
hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.'
|
|
1806
|
+
+ (local
|
|
1807
|
+
? ` The relay marked self is the one you run on: name it (or "${REMOTE_RELAY_LOCAL_ALIAS}") to start and use sessions here, no mention needed. At most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time, and they run on this relay's accounts.`
|
|
1808
|
+
: ''),
|
|
1638
1809
|
},
|
|
1639
1810
|
};
|
|
1640
1811
|
}
|
|
1641
1812
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1813
|
+
/**
|
|
1814
|
+
* The relay a call names: `{ relay }` (a registry entry, or the local
|
|
1815
|
+
* target), or `{ failure }`. "this" always means the relay the agent runs
|
|
1816
|
+
* on; its name does too, unless a paired relay answers to that name.
|
|
1817
|
+
*/
|
|
1818
|
+
async function resolveTarget(name) {
|
|
1819
|
+
const query = toText(name).replace(/^@/, '').toLowerCase();
|
|
1820
|
+
const settings = agentSessions();
|
|
1821
|
+
const alias = query === REMOTE_RELAY_LOCAL_ALIAS;
|
|
1822
|
+
const resolved = alias ? null : await registry.resolve(name);
|
|
1823
|
+
if (resolved?.relay) return { relay: resolved.relay };
|
|
1824
|
+
const selfName = toText(await registry.selfName?.());
|
|
1825
|
+
if (alias || (resolved?.error !== 'ambiguous' && selfName && selfName.toLowerCase() === query)) {
|
|
1826
|
+
if (!settings.enabled) {
|
|
1827
|
+
return {
|
|
1828
|
+
failure: failure(403, REMOTE_RELAY_ERROR_CODES.localDisabled,
|
|
1829
|
+
'Agents may not start or use sessions on this relay: its setting "Agents may start and use sessions on this relay" (Settings → Relays → Agent sessions) is off. Ask the user to turn it on; do not retry until then.',
|
|
1830
|
+
{ relay: selfName || REMOTE_RELAY_LOCAL_ALIAS }),
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
if (!repository) {
|
|
1834
|
+
return {
|
|
1835
|
+
failure: failure(503, REMOTE_RELAY_ERROR_CODES.localDisabled,
|
|
1836
|
+
'Sessions on this relay are not available to agents right now: its storage for them could not be opened (see the server log).',
|
|
1837
|
+
{ relay: selfName || REMOTE_RELAY_LOCAL_ALIAS }),
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
return { relay: await localTarget() };
|
|
1650
1841
|
}
|
|
1651
|
-
const
|
|
1842
|
+
const names = Array.isArray(resolved?.names) ? resolved.names : [];
|
|
1843
|
+
const error = resolved?.error === 'ambiguous'
|
|
1844
|
+
? `"${name}" matches several relays (${names.join(', ')}); use the full name.`
|
|
1845
|
+
: `No paired relay is called "${name}". Known relays: ${names.join(', ') || 'none'}${settings.enabled ? `; this relay itself is "${selfName || REMOTE_RELAY_LOCAL_ALIAS}" (or "${REMOTE_RELAY_LOCAL_ALIAS}")` : ''}.`;
|
|
1846
|
+
return { failure: failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names }) };
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* The user's Allow for this conversation's agent to start sessions on this
|
|
1851
|
+
* relay: remembered once given, asked with a card otherwise. Calls that
|
|
1852
|
+
* arrive while the card is open share it and its answer. Resolves
|
|
1853
|
+
* `{ approved:true, asked }` or `{ approved:false, code?, error? }`.
|
|
1854
|
+
*/
|
|
1855
|
+
async function ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal }) {
|
|
1856
|
+
const remembered = () => {
|
|
1857
|
+
try {
|
|
1858
|
+
return repository.hasLocalSessionApproval(conversationId) === true;
|
|
1859
|
+
} catch {
|
|
1860
|
+
return false;
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
if (remembered()) return { approved: true, asked: false };
|
|
1864
|
+
const open = localApprovalsByConversation.get(conversationId);
|
|
1865
|
+
if (open) {
|
|
1866
|
+
const shared = await open;
|
|
1867
|
+
return shared.approved ? { approved: true, asked: false } : shared;
|
|
1868
|
+
}
|
|
1869
|
+
const asking = (async () => {
|
|
1870
|
+
const decision = await requestApproval({
|
|
1871
|
+
conversationId,
|
|
1872
|
+
callerContext: caller,
|
|
1873
|
+
relay: relayTarget(relay),
|
|
1874
|
+
action,
|
|
1875
|
+
args,
|
|
1876
|
+
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
1877
|
+
signal,
|
|
1878
|
+
localSessions: true,
|
|
1879
|
+
});
|
|
1880
|
+
if (decision !== true && decision?.approved !== true) {
|
|
1881
|
+
return {
|
|
1882
|
+
approved: false,
|
|
1883
|
+
code: toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
|
|
1884
|
+
error: toText(decision?.error)
|
|
1885
|
+
|| 'The user did not allow this agent to start a session on this relay. Go on without it, or ask the user before trying again.',
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
try {
|
|
1889
|
+
repository.recordLocalSessionApproval(conversationId, caller.processingRowId || null);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
// Not remembered: the next create asks again.
|
|
1892
|
+
try {
|
|
1893
|
+
logger?.warn?.(`${LOG_PREFIX} local session approval not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
1894
|
+
} catch {}
|
|
1895
|
+
}
|
|
1896
|
+
return { approved: true, asked: true };
|
|
1897
|
+
})();
|
|
1898
|
+
localApprovalsByConversation.set(conversationId, asking);
|
|
1899
|
+
try {
|
|
1900
|
+
return await asking;
|
|
1901
|
+
} finally {
|
|
1902
|
+
localApprovalsByConversation.delete(conversationId);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* The local target's own rules for create_session, before anyone is asked:
|
|
1908
|
+
* one level, and the cap. Null when the call may go on (the caller then
|
|
1909
|
+
* holds a place under the cap until its prompt is queued).
|
|
1910
|
+
*/
|
|
1911
|
+
function refuseLocalCreate(conversationId, relay) {
|
|
1912
|
+
let origin = null;
|
|
1913
|
+
let active = [];
|
|
1914
|
+
try {
|
|
1915
|
+
origin = repository.getConversationOrigin(conversationId);
|
|
1916
|
+
active = repository.listActiveLocalSessions(conversationId);
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
try {
|
|
1919
|
+
logger?.warn?.(`${LOG_PREFIX} local session rules unreadable conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
1920
|
+
} catch {}
|
|
1921
|
+
return failure(503, REMOTE_RELAY_ERROR_CODES.localDisabled,
|
|
1922
|
+
'Sessions on this relay are not available to agents right now: its storage for them could not be read (see the server log).',
|
|
1923
|
+
{ relay: relay.name });
|
|
1924
|
+
}
|
|
1925
|
+
if (origin) {
|
|
1926
|
+
return failure(403, REMOTE_RELAY_ERROR_CODES.nestedSession,
|
|
1927
|
+
'A session that an agent created cannot create sessions. Do the work in this session, or say in your reply what the agent that prompted you should start.',
|
|
1928
|
+
{ relay: relay.name });
|
|
1929
|
+
}
|
|
1930
|
+
const limit = REMOTE_RELAY_LIMITS.localActiveSessions;
|
|
1931
|
+
const starting = localCreatesByConversation.get(conversationId) || 0;
|
|
1932
|
+
if (active.length + starting >= limit) {
|
|
1933
|
+
return failure(409, REMOTE_RELAY_ERROR_CODES.sessionLimit,
|
|
1934
|
+
`${limit} sessions you started are still working; wait for one to finish.`,
|
|
1935
|
+
{
|
|
1936
|
+
relay: relay.name,
|
|
1937
|
+
limit,
|
|
1938
|
+
sessions: active.slice(0, limit),
|
|
1939
|
+
note: 'Use wait (or read_session) on one of these sessions; create_session works again once one has no turn queued or running.',
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
return null;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function runRelayAction({ conversationId, action, args, req, signal, waitNote }) {
|
|
1946
|
+
const target = await resolveTarget(args.relay);
|
|
1947
|
+
if (!target.relay) return target.failure;
|
|
1948
|
+
const relay = target.relay;
|
|
1949
|
+
const local = relay.local === true;
|
|
1652
1950
|
const write = isRemoteRelayWriteAction(action);
|
|
1653
1951
|
const finish = (result, session) => {
|
|
1654
1952
|
if (write) {
|
|
@@ -1665,21 +1963,30 @@ export function createRemoteRelayDispatcher({
|
|
|
1665
1963
|
return result;
|
|
1666
1964
|
};
|
|
1667
1965
|
|
|
1668
|
-
|
|
1966
|
+
// A paired relay is gated by the user's mention and its permission. The
|
|
1967
|
+
// local target has neither: the setting let the call in (resolveTarget).
|
|
1968
|
+
const access = local ? 'user' : relayAccess(conversationId, relay);
|
|
1669
1969
|
if (!access) {
|
|
1670
1970
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
|
|
1671
1971
|
`Relay "${relay.name}" is locked in this conversation. Ask the user to mention @${relay.name} (or its name) in a message to allow work there.`,
|
|
1672
1972
|
{ relay: relay.name }));
|
|
1673
1973
|
}
|
|
1674
|
-
if (!remoteRelayPermissionAllows(relay.permission, action)) {
|
|
1974
|
+
if (!local && !remoteRelayPermissionAllows(relay.permission, action)) {
|
|
1675
1975
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
1676
1976
|
`Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${REMOTE_RELAY_ACTION_PERMISSION[action]}". The user can change this in Settings → Relays.`,
|
|
1677
1977
|
{ relay: relay.name, permission: relay.permission }));
|
|
1678
1978
|
}
|
|
1979
|
+
if (local && OWN_SESSION_ACTIONS.has(action) && isOwnConversation({ conversationId }, args.session)) {
|
|
1980
|
+
return finish(failure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
|
|
1981
|
+
`Session ${args.session} is the conversation you are running in: an agent cannot ${action} itself. Use the id of another session (list_sessions, or the one create_session returned).`,
|
|
1982
|
+
{ relay: relay.name, session: args.session }));
|
|
1983
|
+
}
|
|
1679
1984
|
|
|
1680
1985
|
const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
|
|
1681
1986
|
caller.mode = toText(caller.mode).toLowerCase();
|
|
1682
|
-
|
|
1987
|
+
// A call on this relay itself crosses no relay: it carries the hops of the
|
|
1988
|
+
// prompt it acts on unchanged.
|
|
1989
|
+
const hops = nonNegativeInt(caller.hops) + (local ? 0 : 1);
|
|
1683
1990
|
// The hop limit stops a prompt from travelling on to a third relay. A
|
|
1684
1991
|
// relay the user unlocked here stays in reach whatever prompts arrive,
|
|
1685
1992
|
// the relay the prompts came from may always be answered, and reading
|
|
@@ -1700,11 +2007,42 @@ export function createRemoteRelayDispatcher({
|
|
|
1700
2007
|
{ relay: relay.name, retryAfterSeconds }));
|
|
1701
2008
|
}
|
|
1702
2009
|
|
|
2010
|
+
if (action === 'create_session') {
|
|
2011
|
+
const problem = createSessionProblem(caller, args);
|
|
2012
|
+
if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const localCreate = local && action === 'create_session';
|
|
2016
|
+
let holdsLocalCreate = false;
|
|
2017
|
+
const releaseLocalCreate = () => {
|
|
2018
|
+
if (!holdsLocalCreate) return;
|
|
2019
|
+
holdsLocalCreate = false;
|
|
2020
|
+
adjustLocalCreates(conversationId, -1);
|
|
2021
|
+
};
|
|
2022
|
+
if (localCreate) {
|
|
2023
|
+
const refusal = refuseLocalCreate(conversationId, relay);
|
|
2024
|
+
if (refusal) return finish(refusal);
|
|
2025
|
+
adjustLocalCreates(conversationId, 1);
|
|
2026
|
+
holdsLocalCreate = true;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1703
2029
|
adjustInflight(conversationId, 1);
|
|
1704
2030
|
try {
|
|
1705
2031
|
const prompting = isRemoteRelayPromptAction(action);
|
|
1706
2032
|
let asked = false;
|
|
1707
|
-
if (
|
|
2033
|
+
if (localCreate) {
|
|
2034
|
+
const decision = await ensureLocalSessionApproval({ conversationId, caller, relay, action, args, signal });
|
|
2035
|
+
if (!decision.approved) {
|
|
2036
|
+
return finish(failure(403, decision.code || REMOTE_RELAY_ERROR_CODES.approvalDenied, decision.error, { relay: relay.name }));
|
|
2037
|
+
}
|
|
2038
|
+
// The card this call raised showed its prompt: it is this call's
|
|
2039
|
+
// approval too, and the user has just looked at what the agent does.
|
|
2040
|
+
if (decision.asked) {
|
|
2041
|
+
asked = true;
|
|
2042
|
+
agentPrompts(conversationId).count = 0;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
if (!asked && prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
|
|
1708
2046
|
// Agents that prompt each other can go on without the user for good:
|
|
1709
2047
|
// from time to time the user is asked, in any relay mode.
|
|
1710
2048
|
const decision = await requestApproval({
|
|
@@ -1721,7 +2059,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1721
2059
|
if (!approved) {
|
|
1722
2060
|
const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
|
|
1723
2061
|
const error = toText(decision?.error)
|
|
1724
|
-
|| `The agent has sent ${agentPrompts(conversationId).count} prompts to other relays' agents since the user last wrote, and the user did not allow more for now.`;
|
|
2062
|
+
|| `The agent has sent ${agentPrompts(conversationId).count} prompts to ${local ? 'other agents' : 'other relays\' agents'} since the user last wrote, and the user did not allow more for now.`;
|
|
1725
2063
|
return finish(failure(403, code, error, { relay: relay.name }));
|
|
1726
2064
|
}
|
|
1727
2065
|
agentPrompts(conversationId).count = 0;
|
|
@@ -1741,15 +2079,16 @@ export function createRemoteRelayDispatcher({
|
|
|
1741
2079
|
if (!approved) {
|
|
1742
2080
|
const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
|
|
1743
2081
|
const error = toText(decision?.error)
|
|
1744
|
-
|| `The user did not allow this ${action.replace(/_/g, ' ')} on relay "${relay.name}".`;
|
|
2082
|
+
|| `The user did not allow this ${action.replace(/_/g, ' ')} on ${local ? 'this relay' : `relay "${relay.name}"`}.`;
|
|
1745
2083
|
return finish(failure(403, code, error, { relay: relay.name }));
|
|
1746
2084
|
}
|
|
1747
2085
|
}
|
|
1748
2086
|
|
|
1749
|
-
const ctx = { relay, caller, hops, conversationId, signal };
|
|
2087
|
+
const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
|
|
1750
2088
|
const body = await ACTIONS[action](ctx, args);
|
|
1751
2089
|
if (prompting) agentPrompts(conversationId).count += 1;
|
|
1752
2090
|
const { summary, ...rest } = body;
|
|
2091
|
+
const note = [rest.note, waitNote].map(toText).filter(Boolean).join(' ');
|
|
1753
2092
|
return finish({
|
|
1754
2093
|
status: 200,
|
|
1755
2094
|
body: {
|
|
@@ -1757,6 +2096,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1757
2096
|
relay: relay.name,
|
|
1758
2097
|
summary: summary || summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
1759
2098
|
...rest,
|
|
2099
|
+
...(note ? { note } : {}),
|
|
1760
2100
|
},
|
|
1761
2101
|
});
|
|
1762
2102
|
} catch (error) {
|
|
@@ -1766,6 +2106,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1766
2106
|
if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
|
|
1767
2107
|
throw error;
|
|
1768
2108
|
} finally {
|
|
2109
|
+
releaseLocalCreate();
|
|
1769
2110
|
adjustInflight(conversationId, -1);
|
|
1770
2111
|
}
|
|
1771
2112
|
}
|
|
@@ -1776,7 +2117,10 @@ export function createRemoteRelayDispatcher({
|
|
|
1776
2117
|
* the caller hangs up.
|
|
1777
2118
|
*/
|
|
1778
2119
|
async function dispatch({ conversationId, action, args, req, signal } = {}) {
|
|
1779
|
-
|
|
2120
|
+
// wait_seconds is cut to this relay's "longest wait" setting, for every
|
|
2121
|
+
// target: the waiting is done here, by polling the target.
|
|
2122
|
+
const { maxWaitSeconds } = agentSessions();
|
|
2123
|
+
const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action }, { maxWaitSeconds });
|
|
1780
2124
|
if (!validation.ok) return failure(400, validation.code, validation.error);
|
|
1781
2125
|
const requestedConversationId = toText(conversationId);
|
|
1782
2126
|
let callerConversationId = requestedConversationId;
|
|
@@ -1786,6 +2130,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1786
2130
|
if (!callerConversationId) {
|
|
1787
2131
|
return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'conversationId is required');
|
|
1788
2132
|
}
|
|
2133
|
+
const waitNote = validation.waitLimitedFrom
|
|
2134
|
+
? `wait_seconds ${validation.waitLimitedFrom} is more than this relay allows per call (${validation.args.wait_seconds}, set by its owner); it waited at most ${validation.args.wait_seconds} s.`
|
|
2135
|
+
: '';
|
|
1789
2136
|
try {
|
|
1790
2137
|
if (validation.action === 'list_relays') return await listRelays(callerConversationId);
|
|
1791
2138
|
return await runRelayAction({
|
|
@@ -1794,6 +2141,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1794
2141
|
args: validation.args,
|
|
1795
2142
|
req,
|
|
1796
2143
|
signal,
|
|
2144
|
+
waitNote,
|
|
1797
2145
|
});
|
|
1798
2146
|
} catch (error) {
|
|
1799
2147
|
try {
|