@oar-sh/oar 0.9.5 → 0.9.7
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/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -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 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- 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 +160 -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 +347 -31
- 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 +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- 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 +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- 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/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -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 +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- 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/git-remote-service.mjs +115 -0
- 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/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- 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 +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- 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/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
4
4
|
|
|
5
|
+
import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
|
|
5
6
|
import {
|
|
6
7
|
REMOTE_RELAY_APPROVAL_MODES,
|
|
8
|
+
REMOTE_RELAY_CLOUD_PROVIDER,
|
|
7
9
|
REMOTE_RELAY_EFFORT_PATTERN,
|
|
8
10
|
REMOTE_RELAY_ERROR_CODES,
|
|
9
11
|
REMOTE_RELAY_LIMITS,
|
|
12
|
+
REMOTE_RELAY_LOCAL_ALIAS,
|
|
13
|
+
REMOTE_RELAY_LOCAL_ID,
|
|
10
14
|
REMOTE_RELAY_PROVIDERS,
|
|
11
15
|
isRemoteRelayApprovalQuestion,
|
|
16
|
+
isRemoteRelayPromptAction,
|
|
12
17
|
isRemoteRelayWriteAction,
|
|
13
18
|
normalizeRemoteRelayOrigin,
|
|
14
19
|
remoteConversationUrl,
|
|
@@ -30,6 +35,19 @@ import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
|
30
35
|
// → permission → hop limit → rate limit → approval (write actions in
|
|
31
36
|
// ask/plan) → execute against the remote relay's existing API
|
|
32
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
|
+
//
|
|
33
51
|
// The remote is reached only through the outbound client (auth, origin and
|
|
34
52
|
// hop headers are its job). Reply detection reads the remote's own transcript:
|
|
35
53
|
// the assistant row whose sourceMessageId is the message we queued, with the
|
|
@@ -71,7 +89,16 @@ const TITLE_MAX = 80;
|
|
|
71
89
|
|
|
72
90
|
// The provider settings routes a relay serves; github (Copilot) has none with
|
|
73
91
|
// models — its catalogue is /api/models plus /api/status defaultModel.
|
|
74
|
-
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']);
|
|
75
102
|
// How /api/models `providersByModel` names each provider.
|
|
76
103
|
const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
77
104
|
github: 'github-copilot',
|
|
@@ -79,6 +106,7 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
|
|
|
79
106
|
claude: 'claude',
|
|
80
107
|
cursor: 'cursor',
|
|
81
108
|
grok: 'grok',
|
|
109
|
+
'claude-cloud': 'claude-cloud',
|
|
82
110
|
});
|
|
83
111
|
// Worker statuses that mean "working on something right now" (scope active).
|
|
84
112
|
const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
@@ -90,7 +118,6 @@ const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
|
|
|
90
118
|
// marker counts only there: a normal reply that quotes a relay error further
|
|
91
119
|
// down is a reply, not a failure.
|
|
92
120
|
const FAILURE_TEXT_PATTERNS = Object.freeze([
|
|
93
|
-
/^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
|
|
94
121
|
/^relay recovery limit reached after \d+ attempts/i,
|
|
95
122
|
/^relay timeout after \d+ attempts/i,
|
|
96
123
|
]);
|
|
@@ -184,7 +211,7 @@ function compactQuery(query = {}) {
|
|
|
184
211
|
|
|
185
212
|
function looksLikeFailureText(text) {
|
|
186
213
|
const value = toText(text);
|
|
187
|
-
return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
|
|
214
|
+
return isFailureNoteText(value) || FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
|
|
188
215
|
}
|
|
189
216
|
|
|
190
217
|
function normalizeProvider(value) {
|
|
@@ -340,7 +367,7 @@ const PERMISSION_DESCRIPTIONS = Object.freeze({
|
|
|
340
367
|
});
|
|
341
368
|
|
|
342
369
|
function relayTarget(relay) {
|
|
343
|
-
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 } : {}) };
|
|
344
371
|
}
|
|
345
372
|
|
|
346
373
|
/**
|
|
@@ -517,6 +544,12 @@ export function settleRemoteReply(messages, messageId) {
|
|
|
517
544
|
export function createRemoteRelayDispatcher({
|
|
518
545
|
registry,
|
|
519
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,
|
|
520
553
|
repository,
|
|
521
554
|
getCallerContext = async () => null,
|
|
522
555
|
requestApproval = async () => false,
|
|
@@ -534,6 +567,17 @@ export function createRemoteRelayDispatcher({
|
|
|
534
567
|
const inflightByConversation = new Map();
|
|
535
568
|
const callTimesByConversation = new Map();
|
|
536
569
|
const writeTimesByConversation = new Map();
|
|
570
|
+
// The loop guard: prompts sent to other relays' agents per conversation,
|
|
571
|
+
// counted from the user's latest own message. Kept in memory: a restart
|
|
572
|
+
// ends a loop of agents by itself.
|
|
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();
|
|
537
581
|
|
|
538
582
|
// ─── Bookkeeping ───────────────────────────────────────────────────────────
|
|
539
583
|
|
|
@@ -556,6 +600,66 @@ export function createRemoteRelayDispatcher({
|
|
|
556
600
|
}
|
|
557
601
|
}
|
|
558
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Why a relay is open to a conversation: 'user' (the user mentioned it),
|
|
605
|
+
* 'contact' (an agent on that relay wrote to the conversation, so it may be
|
|
606
|
+
* answered), or null (locked).
|
|
607
|
+
*/
|
|
608
|
+
function relayAccess(conversationId, relay) {
|
|
609
|
+
if (isUnlocked(conversationId, relay?.id)) return 'user';
|
|
610
|
+
try {
|
|
611
|
+
if (toText(relay?.relayId) && repository?.hasContactFrom?.(conversationId, relay.relayId)) return 'contact';
|
|
612
|
+
} catch {}
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function agentPrompts(conversationId) {
|
|
617
|
+
let since = null;
|
|
618
|
+
try {
|
|
619
|
+
since = repository?.latestHumanMessageId?.(conversationId) || null;
|
|
620
|
+
} catch {}
|
|
621
|
+
const kept = agentPromptsByConversation.get(conversationId);
|
|
622
|
+
if (kept && kept.since === since) return kept;
|
|
623
|
+
const fresh = { since, count: 0 };
|
|
624
|
+
agentPromptsByConversation.set(conversationId, fresh);
|
|
625
|
+
return fresh;
|
|
626
|
+
}
|
|
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
|
+
|
|
559
663
|
function adjustInflight(conversationId, delta) {
|
|
560
664
|
const next = inflight(conversationId) + delta;
|
|
561
665
|
if (next > 0) inflightByConversation.set(conversationId, next);
|
|
@@ -608,7 +712,7 @@ export function createRemoteRelayDispatcher({
|
|
|
608
712
|
// ─── Remote calls ──────────────────────────────────────────────────────────
|
|
609
713
|
|
|
610
714
|
function call(ctx, method, path, { query, body, timeoutMs } = {}) {
|
|
611
|
-
return client.request(ctx.relay, method, path, {
|
|
715
|
+
return (ctx.relay.local ? localClient : client).request(ctx.relay, method, path, {
|
|
612
716
|
...(query ? { query: compactQuery(query) } : {}),
|
|
613
717
|
...(body !== undefined ? { body } : {}),
|
|
614
718
|
...(timeoutMs ? { timeoutMs } : {}),
|
|
@@ -666,6 +770,8 @@ export function createRemoteRelayDispatcher({
|
|
|
666
770
|
provider: ctx.caller.provider,
|
|
667
771
|
model: ctx.caller.model,
|
|
668
772
|
hops: ctx.hops,
|
|
773
|
+
// The conversation named above lives on the relay that reads this.
|
|
774
|
+
...(ctx.relay.local ? { local: true } : {}),
|
|
669
775
|
});
|
|
670
776
|
}
|
|
671
777
|
|
|
@@ -878,6 +984,9 @@ export function createRemoteRelayDispatcher({
|
|
|
878
984
|
*/
|
|
879
985
|
function buildProviders(catalog) {
|
|
880
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;
|
|
881
990
|
const efforts = groupEffortsByModel(catalog.models, entry.provider, entry.models);
|
|
882
991
|
return efforts.length ? { ...entry, efforts } : entry;
|
|
883
992
|
});
|
|
@@ -909,6 +1018,21 @@ export function createRemoteRelayDispatcher({
|
|
|
909
1018
|
}
|
|
910
1019
|
const settings = catalog.settings[provider];
|
|
911
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
|
+
}
|
|
912
1036
|
// What POST /api/conversation/bootstrap requires, plus "not switched off".
|
|
913
1037
|
const configured = settings.enabled === true
|
|
914
1038
|
&& (provider === 'claude' || provider === 'grok' || settings.configured === true);
|
|
@@ -940,12 +1064,18 @@ export function createRemoteRelayDispatcher({
|
|
|
940
1064
|
return {
|
|
941
1065
|
summary: `relay_info → ${ctx.relay.name}: OAR ${version || '?'}, providers ${configured.join(', ') || 'none'}`,
|
|
942
1066
|
name: toText(identity?.name) || ctx.relay.name,
|
|
1067
|
+
...(ctx.relay.local ? { self: true } : {}),
|
|
943
1068
|
url: ctx.relay.url,
|
|
944
|
-
openUrl: remoteConversationUrl(ctx.relay.url, ''),
|
|
1069
|
+
openUrl: remoteConversationUrl(ctx.relay.url, '') || null,
|
|
945
1070
|
version,
|
|
946
1071
|
platform: toText(status.platform) || toText(identity?.platform) || null,
|
|
947
1072
|
protocol: remoteRelays ? nonNegativeInt(remoteRelays.protocol) : 0,
|
|
948
|
-
|
|
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
|
+
} : {}),
|
|
949
1079
|
providers,
|
|
950
1080
|
workspaces: {
|
|
951
1081
|
default: toText(status.defaultSessionWorkspaceRootPath) || toText(status.workspaceRootPath) || null,
|
|
@@ -970,9 +1100,11 @@ export function createRemoteRelayDispatcher({
|
|
|
970
1100
|
}
|
|
971
1101
|
}
|
|
972
1102
|
|
|
973
|
-
function mapSession(row, workerIds) {
|
|
1103
|
+
function mapSession(row, workerIds, ctx = null) {
|
|
974
1104
|
const id = toText(row?.id);
|
|
975
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) : '';
|
|
976
1108
|
return {
|
|
977
1109
|
id,
|
|
978
1110
|
title: toText(row?.title),
|
|
@@ -983,6 +1115,8 @@ export function createRemoteRelayDispatcher({
|
|
|
983
1115
|
messageCount: nonNegativeInt(row?.messageCount),
|
|
984
1116
|
cwd: toText(row?.currentWorkspaceRootPath) || null,
|
|
985
1117
|
...(via ? { via } : {}),
|
|
1118
|
+
...(createdBy ? { createdBy } : {}),
|
|
1119
|
+
...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
|
|
986
1120
|
};
|
|
987
1121
|
}
|
|
988
1122
|
|
|
@@ -1016,7 +1150,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1016
1150
|
let consumed = 0;
|
|
1017
1151
|
for (const row of rows) {
|
|
1018
1152
|
consumed += 1;
|
|
1019
|
-
if (keep(row)) sessions.push(mapSession(row, workerIds));
|
|
1153
|
+
if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
|
|
1020
1154
|
if (sessions.length >= limit) break;
|
|
1021
1155
|
}
|
|
1022
1156
|
scanned += consumed;
|
|
@@ -1133,7 +1267,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1133
1267
|
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1134
1268
|
archived: conversation?.archived === true,
|
|
1135
1269
|
...(via ? { via } : {}),
|
|
1136
|
-
openUrl: remoteConversationUrl(ctx.relay.url, conversationId),
|
|
1270
|
+
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1137
1271
|
},
|
|
1138
1272
|
messages,
|
|
1139
1273
|
...(inFlight ? { inFlight } : {}),
|
|
@@ -1264,7 +1398,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1264
1398
|
}
|
|
1265
1399
|
|
|
1266
1400
|
async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra: given = {} }) {
|
|
1267
|
-
|
|
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;
|
|
1268
1403
|
// `extra.note` (how the effort was settled) goes in front of the outcome's own.
|
|
1269
1404
|
const { note: extraNote, ...extra } = given;
|
|
1270
1405
|
const result = await deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl });
|
|
@@ -1324,22 +1459,27 @@ export function createRemoteRelayDispatcher({
|
|
|
1324
1459
|
const catalog = await loadCatalog(ctx);
|
|
1325
1460
|
const providers = buildProviders(catalog);
|
|
1326
1461
|
const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
|
|
1327
|
-
const provider =
|
|
1462
|
+
const provider = createProvider(ctx.caller, args);
|
|
1463
|
+
const cloud = provider === CLOUD_PROVIDER;
|
|
1328
1464
|
const entry = providers.find((candidate) => candidate.provider === provider);
|
|
1329
1465
|
if (!entry?.configured) {
|
|
1330
1466
|
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
|
|
1331
1467
|
`Relay "${ctx.relay.name}" has no ${provider} provider set up. Providers there: ${configured.join(', ') || 'none'}. Pass provider to pick one.`,
|
|
1332
|
-
{ providers: configured });
|
|
1468
|
+
{ providers: configured, ...(cloud && catalog.settings[CLOUD_PROVIDER] ? { remoteCode: CLOUD_DISABLED_CODE } : {}) });
|
|
1333
1469
|
}
|
|
1334
1470
|
|
|
1335
1471
|
let model = args.model || '';
|
|
1336
1472
|
let modelSource = model ? 'requested' : '';
|
|
1337
1473
|
let note = null;
|
|
1338
|
-
|
|
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) {
|
|
1339
1479
|
model = matchOfferedModel(ctx.caller.model, entry.models);
|
|
1340
1480
|
modelSource = model ? 'same as this session' : '';
|
|
1341
1481
|
}
|
|
1342
|
-
if (!model) {
|
|
1482
|
+
if (!model && !cloud) {
|
|
1343
1483
|
model = entry.defaultModel || entry.models[0] || '';
|
|
1344
1484
|
modelSource = 'remote default';
|
|
1345
1485
|
if (toText(ctx.caller.model)) {
|
|
@@ -1353,7 +1493,10 @@ export function createRemoteRelayDispatcher({
|
|
|
1353
1493
|
// The effort mirrors the caller like the model does: this session's when
|
|
1354
1494
|
// the remote lists it for the model (or lists nothing), else the remote's.
|
|
1355
1495
|
let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
|
|
1356
|
-
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) {
|
|
1357
1500
|
const callerEffort = normalizeEffort(ctx.caller.effort);
|
|
1358
1501
|
const offered = catalogEffortsFor(catalog.models, provider, model);
|
|
1359
1502
|
if (callerEffort && (!offered || offered.includes(callerEffort))) {
|
|
@@ -1377,6 +1520,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1377
1520
|
...(effort ? { reasoningEffort: effort } : {}),
|
|
1378
1521
|
title,
|
|
1379
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 } : {}) } } : {}),
|
|
1380
1526
|
origin,
|
|
1381
1527
|
},
|
|
1382
1528
|
});
|
|
@@ -1394,7 +1540,8 @@ export function createRemoteRelayDispatcher({
|
|
|
1394
1540
|
};
|
|
1395
1541
|
bootstrap = await bootstrapWith('');
|
|
1396
1542
|
}
|
|
1397
|
-
|
|
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);
|
|
1398
1545
|
const session = toText(bootstrap?.conversationId);
|
|
1399
1546
|
if (!session) {
|
|
1400
1547
|
throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
|
|
@@ -1403,7 +1550,11 @@ export function createRemoteRelayDispatcher({
|
|
|
1403
1550
|
const boundModel = toText(bootstrap?.selectedModel) || model;
|
|
1404
1551
|
let sent;
|
|
1405
1552
|
try {
|
|
1406
|
-
|
|
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?.();
|
|
1407
1558
|
} catch (error) {
|
|
1408
1559
|
if (!choice.effort || !isEffortRejection(error)) throw error;
|
|
1409
1560
|
// The session exists by now: say so, or the agent starts a second one.
|
|
@@ -1415,7 +1566,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1415
1566
|
session,
|
|
1416
1567
|
...(remoteCode ? { remoteCode } : {}),
|
|
1417
1568
|
...(supported.length ? { supportedEfforts: supported } : {}),
|
|
1418
|
-
openUrl: remoteConversationUrl(ctx.relay.url, session),
|
|
1569
|
+
openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
|
|
1419
1570
|
note: 'Use send with this session and an effort it takes (or none); do not create another session.',
|
|
1420
1571
|
});
|
|
1421
1572
|
}
|
|
@@ -1429,6 +1580,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1429
1580
|
modelSource,
|
|
1430
1581
|
mode: toText(bootstrap?.preferredRelayMode) || mode || null,
|
|
1431
1582
|
title,
|
|
1583
|
+
...(cloud ? { repo: toText(bootstrap?.cloud?.repoUrl) || args.repo, branch: toText(bootstrap?.cloud?.branch) || args.branch || null } : {}),
|
|
1432
1584
|
...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
|
|
1433
1585
|
...(note ? { modelNote: note } : {}),
|
|
1434
1586
|
...effortResult(choice),
|
|
@@ -1436,6 +1588,37 @@ export function createRemoteRelayDispatcher({
|
|
|
1436
1588
|
});
|
|
1437
1589
|
}
|
|
1438
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
|
+
|
|
1439
1622
|
/** The answer body the remote's answer route expects for this question. */
|
|
1440
1623
|
function buildAnswer(question, args) {
|
|
1441
1624
|
const fields = schemaFields(question.requestSchema);
|
|
@@ -1474,6 +1657,11 @@ export function createRemoteRelayDispatcher({
|
|
|
1474
1657
|
`Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
|
|
1475
1658
|
}
|
|
1476
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
|
+
}
|
|
1477
1665
|
if (isRemoteRelayApprovalQuestion(question)) {
|
|
1478
1666
|
throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
1479
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.`,
|
|
@@ -1588,14 +1776,20 @@ export function createRemoteRelayDispatcher({
|
|
|
1588
1776
|
|
|
1589
1777
|
async function listRelays(conversationId) {
|
|
1590
1778
|
const relays = (await registry.list()) || [];
|
|
1779
|
+
const settings = agentSessions();
|
|
1780
|
+
const local = settings.enabled ? await localTarget() : null;
|
|
1591
1781
|
const listed = relays.map((relay) => ({
|
|
1592
1782
|
name: relay.name,
|
|
1593
1783
|
url: relay.url,
|
|
1594
1784
|
online: toText(relay.lastStatus).toLowerCase() === 'online',
|
|
1595
1785
|
version: relay.version || null,
|
|
1596
1786
|
permission: relay.permission,
|
|
1597
|
-
unlocked:
|
|
1787
|
+
unlocked: relayAccess(conversationId, relay) !== null,
|
|
1598
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
|
+
}
|
|
1599
1793
|
const unlocked = listed.filter((relay) => relay.unlocked).length;
|
|
1600
1794
|
return {
|
|
1601
1795
|
status: 200,
|
|
@@ -1604,21 +1798,155 @@ export function createRemoteRelayDispatcher({
|
|
|
1604
1798
|
summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
|
|
1605
1799
|
self: { name: (await registry.selfName?.()) || null },
|
|
1606
1800
|
relays: listed,
|
|
1607
|
-
|
|
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
|
+
: ''),
|
|
1608
1809
|
},
|
|
1609
1810
|
};
|
|
1610
1811
|
}
|
|
1611
1812
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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() };
|
|
1841
|
+
}
|
|
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 });
|
|
1620
1924
|
}
|
|
1621
|
-
|
|
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;
|
|
1622
1950
|
const write = isRemoteRelayWriteAction(action);
|
|
1623
1951
|
const finish = (result, session) => {
|
|
1624
1952
|
if (write) {
|
|
@@ -1635,23 +1963,40 @@ export function createRemoteRelayDispatcher({
|
|
|
1635
1963
|
return result;
|
|
1636
1964
|
};
|
|
1637
1965
|
|
|
1638
|
-
|
|
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);
|
|
1969
|
+
if (!access) {
|
|
1639
1970
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
|
|
1640
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.`,
|
|
1641
1972
|
{ relay: relay.name }));
|
|
1642
1973
|
}
|
|
1643
|
-
if (!remoteRelayPermissionAllows(relay.permission, action)) {
|
|
1974
|
+
if (!local && !remoteRelayPermissionAllows(relay.permission, action)) {
|
|
1644
1975
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
|
|
1645
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.`,
|
|
1646
1977
|
{ relay: relay.name, permission: relay.permission }));
|
|
1647
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
|
+
}
|
|
1648
1984
|
|
|
1649
1985
|
const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
|
|
1650
1986
|
caller.mode = toText(caller.mode).toLowerCase();
|
|
1651
|
-
|
|
1652
|
-
|
|
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);
|
|
1990
|
+
// The hop limit stops a prompt from travelling on to a third relay. A
|
|
1991
|
+
// relay the user unlocked here stays in reach whatever prompts arrive,
|
|
1992
|
+
// the relay the prompts came from may always be answered, and reading
|
|
1993
|
+
// carries nothing on.
|
|
1994
|
+
const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
|
|
1995
|
+
const answering = !!toText(relay.relayId) && origins.length > 0
|
|
1996
|
+
&& origins.every((id) => id === toText(relay.relayId));
|
|
1997
|
+
if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
|
|
1653
1998
|
return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
|
|
1654
|
-
`This turn
|
|
1999
|
+
`This turn acts on a prompt from another relay's agent (hop ${hops - 1}); passing it on to relay "${relay.name}" would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops. The user can allow it by mentioning @${relay.name} in this conversation.`,
|
|
1655
2000
|
{ relay: relay.name }));
|
|
1656
2001
|
}
|
|
1657
2002
|
|
|
@@ -1662,9 +2007,65 @@ export function createRemoteRelayDispatcher({
|
|
|
1662
2007
|
{ relay: relay.name, retryAfterSeconds }));
|
|
1663
2008
|
}
|
|
1664
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
|
+
|
|
1665
2029
|
adjustInflight(conversationId, 1);
|
|
1666
2030
|
try {
|
|
1667
|
-
|
|
2031
|
+
const prompting = isRemoteRelayPromptAction(action);
|
|
2032
|
+
let asked = false;
|
|
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) {
|
|
2046
|
+
// Agents that prompt each other can go on without the user for good:
|
|
2047
|
+
// from time to time the user is asked, in any relay mode.
|
|
2048
|
+
const decision = await requestApproval({
|
|
2049
|
+
conversationId,
|
|
2050
|
+
callerContext: caller,
|
|
2051
|
+
relay: relayTarget(relay),
|
|
2052
|
+
action,
|
|
2053
|
+
args,
|
|
2054
|
+
summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
2055
|
+
signal,
|
|
2056
|
+
agentPrompts: agentPrompts(conversationId).count,
|
|
2057
|
+
});
|
|
2058
|
+
const approved = decision === true || decision?.approved === true;
|
|
2059
|
+
if (!approved) {
|
|
2060
|
+
const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
|
|
2061
|
+
const error = toText(decision?.error)
|
|
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.`;
|
|
2063
|
+
return finish(failure(403, code, error, { relay: relay.name }));
|
|
2064
|
+
}
|
|
2065
|
+
agentPrompts(conversationId).count = 0;
|
|
2066
|
+
asked = true;
|
|
2067
|
+
}
|
|
2068
|
+
if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
|
|
1668
2069
|
const decision = await requestApproval({
|
|
1669
2070
|
conversationId,
|
|
1670
2071
|
callerContext: caller,
|
|
@@ -1678,14 +2079,16 @@ export function createRemoteRelayDispatcher({
|
|
|
1678
2079
|
if (!approved) {
|
|
1679
2080
|
const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
|
|
1680
2081
|
const error = toText(decision?.error)
|
|
1681
|
-
|| `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}"`}.`;
|
|
1682
2083
|
return finish(failure(403, code, error, { relay: relay.name }));
|
|
1683
2084
|
}
|
|
1684
2085
|
}
|
|
1685
2086
|
|
|
1686
|
-
const ctx = { relay, caller, hops, conversationId, signal };
|
|
2087
|
+
const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
|
|
1687
2088
|
const body = await ACTIONS[action](ctx, args);
|
|
2089
|
+
if (prompting) agentPrompts(conversationId).count += 1;
|
|
1688
2090
|
const { summary, ...rest } = body;
|
|
2091
|
+
const note = [rest.note, waitNote].map(toText).filter(Boolean).join(' ');
|
|
1689
2092
|
return finish({
|
|
1690
2093
|
status: 200,
|
|
1691
2094
|
body: {
|
|
@@ -1693,6 +2096,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1693
2096
|
relay: relay.name,
|
|
1694
2097
|
summary: summary || summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
|
|
1695
2098
|
...rest,
|
|
2099
|
+
...(note ? { note } : {}),
|
|
1696
2100
|
},
|
|
1697
2101
|
});
|
|
1698
2102
|
} catch (error) {
|
|
@@ -1702,6 +2106,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1702
2106
|
if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
|
|
1703
2107
|
throw error;
|
|
1704
2108
|
} finally {
|
|
2109
|
+
releaseLocalCreate();
|
|
1705
2110
|
adjustInflight(conversationId, -1);
|
|
1706
2111
|
}
|
|
1707
2112
|
}
|
|
@@ -1712,7 +2117,10 @@ export function createRemoteRelayDispatcher({
|
|
|
1712
2117
|
* the caller hangs up.
|
|
1713
2118
|
*/
|
|
1714
2119
|
async function dispatch({ conversationId, action, args, req, signal } = {}) {
|
|
1715
|
-
|
|
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 });
|
|
1716
2124
|
if (!validation.ok) return failure(400, validation.code, validation.error);
|
|
1717
2125
|
const requestedConversationId = toText(conversationId);
|
|
1718
2126
|
let callerConversationId = requestedConversationId;
|
|
@@ -1722,6 +2130,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1722
2130
|
if (!callerConversationId) {
|
|
1723
2131
|
return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'conversationId is required');
|
|
1724
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
|
+
: '';
|
|
1725
2136
|
try {
|
|
1726
2137
|
if (validation.action === 'list_relays') return await listRelays(callerConversationId);
|
|
1727
2138
|
return await runRelayAction({
|
|
@@ -1730,6 +2141,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1730
2141
|
args: validation.args,
|
|
1731
2142
|
req,
|
|
1732
2143
|
signal,
|
|
2144
|
+
waitNote,
|
|
1733
2145
|
});
|
|
1734
2146
|
} catch (error) {
|
|
1735
2147
|
try {
|