@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The relay's console, kept in a file.
|
|
4
|
+
//
|
|
5
|
+
// The relay writes what it does to its console: a terminal, a tmux pane, a
|
|
6
|
+
// service manager's journal or nothing at all, depending on how it was
|
|
7
|
+
// started. After an incident or a restart that output is what one wants to
|
|
8
|
+
// read, so the runtime copies it into `<log dir>/relay-console.log`, with a
|
|
9
|
+
// time stamp per line and a size limit.
|
|
10
|
+
//
|
|
11
|
+
// The copy is taken from the console's methods rather than from stdout: the
|
|
12
|
+
// terminal console (tty-console) redraws its prompt on stdout, and none of
|
|
13
|
+
// that belongs in a log.
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { format } from 'util';
|
|
18
|
+
|
|
19
|
+
export const RELAY_CONSOLE_LOG_FILE = 'relay-console.log';
|
|
20
|
+
export const RELAY_CONSOLE_LOG_DISABLE_ENV = 'OAR_NO_CONSOLE_LOG';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
const DEFAULT_KEEP = 3;
|
|
24
|
+
const LEVELS = Object.freeze(['log', 'info', 'warn', 'error', 'debug']);
|
|
25
|
+
const ANSI_STYLE_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
26
|
+
// A token in a URL, whatever its value: the page is opened with `?token=`.
|
|
27
|
+
const TOKEN_PARAM_PATTERN = /([?&]token=)[^&\s"'<>]+/gi;
|
|
28
|
+
const SECRET_MIN_LENGTH = 6;
|
|
29
|
+
|
|
30
|
+
function escapeRegExp(value) {
|
|
31
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where the console log goes: the log directory the environment names, else
|
|
36
|
+
* `logs` under a data directory the environment names (a relay with its own
|
|
37
|
+
* data keeps its own log), else `logs` next to the server.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveRelayConsoleLogPath({ env = process.env, serverDir, pathImpl = path } = {}) {
|
|
40
|
+
const logDir = String(env?.COPILOT_WEB_RELAY_LOG_DIR || '').trim();
|
|
41
|
+
const dataDir = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
|
|
42
|
+
const dir = logDir
|
|
43
|
+
|| (dataDir ? pathImpl.join(dataDir, 'logs') : pathImpl.join(String(serverDir || process.cwd()), 'logs'));
|
|
44
|
+
return pathImpl.join(pathImpl.resolve(dir), RELAY_CONSOLE_LOG_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Copies what the relay writes to its console into `logPath`. Returns null
|
|
49
|
+
* when it is switched off or the file cannot be opened: a log problem must
|
|
50
|
+
* never stop the relay.
|
|
51
|
+
*
|
|
52
|
+
* `{ logPath, setSecrets(values), rewrap(), write(level, text), uninstall() }`:
|
|
53
|
+
* `setSecrets` names values that are never written (the auth token, once the
|
|
54
|
+
* config is read), or a function that returns them, asked at every write so
|
|
55
|
+
* a token that changes is covered; `rewrap` puts the copy in front again
|
|
56
|
+
* after something else replaced the console's methods.
|
|
57
|
+
*/
|
|
58
|
+
export function installRelayConsoleLog({
|
|
59
|
+
logPath,
|
|
60
|
+
env = process.env,
|
|
61
|
+
consoleImpl = console,
|
|
62
|
+
processImpl = process,
|
|
63
|
+
fsImpl = fs,
|
|
64
|
+
now = () => new Date(),
|
|
65
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
66
|
+
keep = DEFAULT_KEEP,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (String(env?.[RELAY_CONSOLE_LOG_DISABLE_ENV] || '').trim() === '1') return null;
|
|
69
|
+
const target = String(logPath || '').trim();
|
|
70
|
+
if (!target) return null;
|
|
71
|
+
|
|
72
|
+
let fd = null;
|
|
73
|
+
let size = 0;
|
|
74
|
+
let secretSource = () => [];
|
|
75
|
+
let secretKey = '';
|
|
76
|
+
let secretPattern = null;
|
|
77
|
+
|
|
78
|
+
function open() {
|
|
79
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
80
|
+
fd = fsImpl.openSync(target, 'a');
|
|
81
|
+
try {
|
|
82
|
+
size = Number(fsImpl.fstatSync(fd).size) || 0;
|
|
83
|
+
} catch {
|
|
84
|
+
size = 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function close() {
|
|
89
|
+
if (fd === null) return;
|
|
90
|
+
try { fsImpl.closeSync(fd); } catch {}
|
|
91
|
+
fd = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// relay-console.log → .1 → .2 …; the oldest of `keep` is dropped.
|
|
95
|
+
function rotate() {
|
|
96
|
+
close();
|
|
97
|
+
try {
|
|
98
|
+
for (let index = keep; index >= 1; index -= 1) {
|
|
99
|
+
const from = index === 1 ? target : `${target}.${index - 1}`;
|
|
100
|
+
const to = `${target}.${index}`;
|
|
101
|
+
if (!fsImpl.existsSync(from)) continue;
|
|
102
|
+
try { fsImpl.rmSync(to, { force: true }); } catch {}
|
|
103
|
+
fsImpl.renameSync(from, to);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// A file that cannot be moved (held open elsewhere) is written on.
|
|
107
|
+
}
|
|
108
|
+
open();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function currentSecretPattern() {
|
|
112
|
+
let values = [];
|
|
113
|
+
try {
|
|
114
|
+
values = secretSource() || [];
|
|
115
|
+
} catch {}
|
|
116
|
+
const secrets = (Array.isArray(values) ? values : [values])
|
|
117
|
+
.map((value) => String(value ?? '').trim())
|
|
118
|
+
.filter((value) => value.length >= SECRET_MIN_LENGTH);
|
|
119
|
+
const key = secrets.join('\n');
|
|
120
|
+
if (key !== secretKey) {
|
|
121
|
+
secretKey = key;
|
|
122
|
+
secretPattern = secrets.length ? new RegExp(secrets.map(escapeRegExp).join('|'), 'g') : null;
|
|
123
|
+
}
|
|
124
|
+
return secretPattern;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scrub(text) {
|
|
128
|
+
const out = String(text).replace(ANSI_STYLE_PATTERN, '').replace(TOKEN_PARAM_PATTERN, '$1[token]');
|
|
129
|
+
const secrets = currentSecretPattern();
|
|
130
|
+
return secrets ? out.replace(secrets, '[token]') : out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Synchronous on purpose: the last lines before an exit are the ones an
|
|
134
|
+
// incident needs.
|
|
135
|
+
function write(level, text) {
|
|
136
|
+
if (fd === null) return;
|
|
137
|
+
try {
|
|
138
|
+
const stamp = now().toISOString();
|
|
139
|
+
const tag = level === 'log' ? '' : `${String(level).toUpperCase()} `;
|
|
140
|
+
const lines = scrub(text).split(/\r?\n/).map((line) => `${stamp} ${tag}${line}\n`).join('');
|
|
141
|
+
if (size > 0 && size + Buffer.byteLength(lines) > maxBytes) rotate();
|
|
142
|
+
if (fd === null) return;
|
|
143
|
+
size += fsImpl.writeSync(fd, lines);
|
|
144
|
+
} catch {
|
|
145
|
+
// A file that stopped taking writes (disk full, handle gone) is given up.
|
|
146
|
+
close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
open();
|
|
152
|
+
if (size > maxBytes) rotate();
|
|
153
|
+
} catch {
|
|
154
|
+
close();
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const wrapped = new Map();
|
|
159
|
+
function wrap() {
|
|
160
|
+
for (const level of LEVELS) {
|
|
161
|
+
const current = consoleImpl[level];
|
|
162
|
+
if (typeof current !== 'function' || current.relayConsoleLog === true) continue;
|
|
163
|
+
const copy = function relayConsoleCopy(...args) {
|
|
164
|
+
try {
|
|
165
|
+
write(level, format(...args));
|
|
166
|
+
} catch {}
|
|
167
|
+
return current.apply(this, args);
|
|
168
|
+
};
|
|
169
|
+
copy.relayConsoleLog = true;
|
|
170
|
+
copy.inner = current;
|
|
171
|
+
consoleImpl[level] = copy;
|
|
172
|
+
wrapped.set(level, copy);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
wrap();
|
|
176
|
+
|
|
177
|
+
// Without a handler Node prints an uncaught error natively, past the console.
|
|
178
|
+
const onUncaught = (error, origin) => {
|
|
179
|
+
write('error', `${origin || 'uncaughtException'}: ${error?.stack || error?.message || error}`);
|
|
180
|
+
};
|
|
181
|
+
processImpl.on?.('uncaughtExceptionMonitor', onUncaught);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
logPath: target,
|
|
185
|
+
write,
|
|
186
|
+
rewrap: wrap,
|
|
187
|
+
setSecrets(values = []) {
|
|
188
|
+
secretSource = typeof values === 'function' ? values : () => values;
|
|
189
|
+
},
|
|
190
|
+
uninstall() {
|
|
191
|
+
for (const [level, copy] of wrapped) {
|
|
192
|
+
if (consoleImpl[level] === copy) consoleImpl[level] = copy.inner;
|
|
193
|
+
}
|
|
194
|
+
wrapped.clear();
|
|
195
|
+
processImpl.off?.('uncaughtExceptionMonitor', onUncaught);
|
|
196
|
+
close();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -33,6 +33,8 @@ import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
|
|
|
33
33
|
|
|
34
34
|
const LOG_PREFIX = '[remote-relays]';
|
|
35
35
|
const APPROVAL_HEADER = 'Remote relay';
|
|
36
|
+
// The once-per-conversation card for sessions an agent starts on this relay.
|
|
37
|
+
const LOCAL_SESSIONS_HEADER = 'Agent sessions';
|
|
36
38
|
const APPROVAL_ALLOW = 'Allow';
|
|
37
39
|
const APPROVAL_DENY = 'Deny';
|
|
38
40
|
const EXCERPT_MAX = 200;
|
|
@@ -115,16 +117,45 @@ function describeRemoteTurn(action, args, caller) {
|
|
|
115
117
|
return parts.join(' · ');
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
/**
|
|
121
|
+
* What a session an agent starts on this relay works on, for the card: the
|
|
122
|
+
* repository (and branch) of a Claude Cloud session, else the folder, else
|
|
123
|
+
* the relay's default folder.
|
|
124
|
+
*/
|
|
125
|
+
function describeLocalSessionTarget(args) {
|
|
126
|
+
const repo = toText(args.repo).replace(/^https?:\/\/(?:www\.)?github\.com\//i, '');
|
|
127
|
+
if (repo) return toText(args.branch) ? `${excerpt(repo)}, branch ${excerpt(args.branch)}` : excerpt(repo);
|
|
128
|
+
return toText(args.cwd) ? excerpt(args.cwd) : 'the default folder';
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
/**
|
|
119
132
|
* The card text: what the agent wants to do where, how the remote turn would
|
|
120
133
|
* run, then the prompt (or the answer) it wants to send, so the user decides
|
|
121
134
|
* on the actual words. `caller` is the calling session's context.
|
|
135
|
+
*
|
|
136
|
+
* `localSessions` is the once-per-conversation card of the local target: the
|
|
137
|
+
* agent wants to start sessions on this relay itself. Allowing it covers the
|
|
138
|
+
* later ones of that conversation, so the card says what they cost.
|
|
122
139
|
*/
|
|
123
|
-
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
|
|
140
|
+
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0, localSessions = false } = {}) {
|
|
141
|
+
const local = relay?.local === true;
|
|
124
142
|
const name = toText(relay?.name) || 'a remote relay';
|
|
143
|
+
const where = local ? 'this relay' : `relay "${name}"`;
|
|
125
144
|
const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
|
|
126
145
|
const target = args.session ? ` (session ${shortId(args.session)})` : '';
|
|
127
|
-
const lines = [
|
|
146
|
+
const lines = [];
|
|
147
|
+
// The loop guard's card: agents that prompt each other without the user.
|
|
148
|
+
if (agentPrompts > 0) {
|
|
149
|
+
lines.push(`The agent has sent ${agentPrompts} prompts to ${local ? 'other agents' : 'agents on other relays'} since your last message. Let it go on?`);
|
|
150
|
+
}
|
|
151
|
+
if (localSessions) {
|
|
152
|
+
const requested = toText(args.provider).toLowerCase();
|
|
153
|
+
const callerProvider = toText(caller?.provider).toLowerCase();
|
|
154
|
+
const provider = requested || (REMOTE_RELAY_PROVIDERS.includes(callerProvider) ? callerProvider : 'github');
|
|
155
|
+
lines.push(`This agent wants to start sessions on this relay (${provider}, ${describeLocalSessionTarget(args)}). Sessions it starts use this relay's accounts.`);
|
|
156
|
+
} else {
|
|
157
|
+
lines.push(`Allow the agent to ${verb} on ${where}${target}?`);
|
|
158
|
+
}
|
|
128
159
|
if (action === 'send' || action === 'create_session') {
|
|
129
160
|
const details = describeRemoteTurn(action, args, caller);
|
|
130
161
|
if (details) lines.push(details);
|
|
@@ -198,29 +229,34 @@ export function createRemoteRelayCallerContext({
|
|
|
198
229
|
return rows.find((row) => row.id === liveId) || rows[0];
|
|
199
230
|
}
|
|
200
231
|
|
|
201
|
-
function
|
|
202
|
-
|
|
232
|
+
function messageOrigin(messageId) {
|
|
233
|
+
return (messageId ? repository?.getMessageOrigin?.(messageId) : null) || null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function originHops(origin) {
|
|
203
237
|
const hops = Number(origin?.hops);
|
|
204
238
|
return Number.isInteger(hops) && hops >= 0 ? hops : 0;
|
|
205
239
|
}
|
|
206
240
|
|
|
207
241
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* turn whose background work it
|
|
242
|
+
* Where a turn's prompt came from (null for a human's): the origin of its
|
|
243
|
+
* own user message, or for a continuation row (no message of its own) that
|
|
244
|
+
* of the latest user message before it, the turn whose background work it
|
|
245
|
+
* continues.
|
|
211
246
|
*/
|
|
212
|
-
function
|
|
213
|
-
if (toText(row.kind).toLowerCase() !== 'continuation') return
|
|
247
|
+
function rowOrigin(conversationId, row) {
|
|
248
|
+
if (toText(row.kind).toLowerCase() !== 'continuation') return messageOrigin(row.id);
|
|
214
249
|
const at = toText(row.timestamp) || toText(row.processing_at);
|
|
215
250
|
const message = at
|
|
216
251
|
? getLatestUserMessageAtOrBefore.get(conversationId, at)
|
|
217
252
|
: getLatestUserMessage.get(conversationId);
|
|
218
|
-
return
|
|
253
|
+
return messageOrigin(message?.id);
|
|
219
254
|
}
|
|
220
255
|
|
|
221
256
|
/**
|
|
222
257
|
* `{ conversationId, title, provider, model, effort, mode, processingRowId,
|
|
223
|
-
* attemptId, userMessageId, hops }` for the conversation
|
|
258
|
+
* attemptId, userMessageId, hops, originRelayIds }` for the conversation
|
|
259
|
+
* making the call.
|
|
224
260
|
* `effort` is the reasoning effort of the live turn, else the one the
|
|
225
261
|
* conversation prefers ('' when neither is known).
|
|
226
262
|
* `hops` is how many relays the current turn's prompt already crossed: 0
|
|
@@ -229,6 +265,8 @@ export function createRemoteRelayCallerContext({
|
|
|
229
265
|
* conversation's processing rows wins (a background continuation counts as
|
|
230
266
|
* the turn it continues); without a turn (an off-turn call) the latest user
|
|
231
267
|
* message decides.
|
|
268
|
+
* `originRelayIds` are the relays (their instance ids) those prompts came
|
|
269
|
+
* from: empty when the agent acts on the user's words alone.
|
|
232
270
|
*/
|
|
233
271
|
function getCallerContext(conversationId) {
|
|
234
272
|
const id = toText(conversationId);
|
|
@@ -236,9 +274,11 @@ export function createRemoteRelayCallerContext({
|
|
|
236
274
|
const runtime = getRuntimeSession.get(id) || null;
|
|
237
275
|
const rows = listProcessingRows.all(id);
|
|
238
276
|
const live = liveTurnRow(id, rows);
|
|
239
|
-
const
|
|
240
|
-
?
|
|
241
|
-
:
|
|
277
|
+
const origins = (rows.length
|
|
278
|
+
? rows.map((row) => rowOrigin(id, row))
|
|
279
|
+
: [messageOrigin(getLatestUserMessage.get(id)?.id)]).filter(Boolean);
|
|
280
|
+
const hops = Math.max(0, ...origins.map(originHops));
|
|
281
|
+
const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
|
|
242
282
|
return {
|
|
243
283
|
conversationId: id,
|
|
244
284
|
title: toText(conversation?.title),
|
|
@@ -250,6 +290,7 @@ export function createRemoteRelayCallerContext({
|
|
|
250
290
|
attemptId: toText(live?.attempt_id) || null,
|
|
251
291
|
userMessageId: live?.id || null,
|
|
252
292
|
hops,
|
|
293
|
+
originRelayIds,
|
|
253
294
|
};
|
|
254
295
|
}
|
|
255
296
|
|
|
@@ -270,29 +311,47 @@ export function createRemoteRelayCallerContext({
|
|
|
270
311
|
* denied, timed out, cancelled with the turn, or no turn to ask on (fails
|
|
271
312
|
* closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
|
|
272
313
|
* lives (8 h by default); `signal` withdraws the card early.
|
|
314
|
+
* `agentPrompts` (the loop guard) is how many prompts the agent has sent to
|
|
315
|
+
* other relays' agents since the user last wrote; the card then asks for
|
|
316
|
+
* that, in any relay mode.
|
|
317
|
+
* `localSessions` makes it the card for starting sessions on this relay
|
|
318
|
+
* itself, asked once per conversation (the dispatcher remembers the Allow).
|
|
273
319
|
*/
|
|
274
|
-
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
|
|
320
|
+
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0, localSessions = false } = {}) {
|
|
275
321
|
const rowId = toText(callerContext?.processingRowId);
|
|
276
322
|
const row = rowId ? getQueueRow.get(rowId) : null;
|
|
277
323
|
if (!row || row.status !== 'processing') {
|
|
278
324
|
return {
|
|
279
325
|
approved: false,
|
|
280
326
|
code: REMOTE_RELAY_ERROR_CODES.noTurn,
|
|
281
|
-
error:
|
|
327
|
+
error: localSessions
|
|
328
|
+
? 'Starting sessions on this relay needs the user\'s approval once per conversation, but the conversation has no running turn to ask on.'
|
|
329
|
+
: 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.',
|
|
282
330
|
};
|
|
283
331
|
}
|
|
284
332
|
|
|
285
333
|
const createdAt = now().toISOString();
|
|
286
334
|
const questionId = String(uuid());
|
|
287
335
|
const relayMode = normalizeMode(row.relay_mode);
|
|
288
|
-
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
|
|
336
|
+
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts, localSessions });
|
|
337
|
+
const local = relay?.local === true;
|
|
338
|
+
let rationale = `Write actions on ${local ? 'other sessions of this relay' : 'other relays'} need approval in ${relayMode} mode.`;
|
|
339
|
+
if (agentPrompts > 0) {
|
|
340
|
+
rationale = local
|
|
341
|
+
? 'Agents that keep prompting each other need your go from time to time.'
|
|
342
|
+
: 'Agents on different relays that keep prompting each other need your go from time to time.';
|
|
343
|
+
} else if (localSessions) {
|
|
344
|
+
rationale = 'Allow covers every session this conversation\'s agent starts on this relay; Deny refuses this one.';
|
|
345
|
+
}
|
|
346
|
+
// The same source for every card of this tool: it is what keeps agents
|
|
347
|
+
// (other relays' and this relay's own) from seeing or answering one.
|
|
289
348
|
const context = sanitizeRelayQuestionContext({
|
|
290
349
|
source: REMOTE_RELAY_APPROVAL_SOURCE,
|
|
291
|
-
rationale
|
|
350
|
+
rationale,
|
|
292
351
|
queueMessageId: row.id,
|
|
293
352
|
conversationId: toText(conversationId) || row.conversation_id,
|
|
294
353
|
relayMode,
|
|
295
|
-
header: APPROVAL_HEADER,
|
|
354
|
+
header: localSessions ? LOCAL_SESSIONS_HEADER : APPROVAL_HEADER,
|
|
296
355
|
}, { normalizeRelayMode: normalizeMode, defaultRelayMode });
|
|
297
356
|
questionStatements.insertQuestion.run(
|
|
298
357
|
questionId,
|
|
@@ -121,6 +121,15 @@ function networkError(relay, error, timeoutMs) {
|
|
|
121
121
|
);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The error for a relay that answered with a non-2xx status and (maybe) a JSON
|
|
126
|
+
* body. Shared with the in-process loopback, so a refusal of this relay's own
|
|
127
|
+
* routes reads exactly like a paired relay's.
|
|
128
|
+
*/
|
|
129
|
+
export function remoteRelayHttpError(relay, status, body) {
|
|
130
|
+
return httpError(relay, status, body);
|
|
131
|
+
}
|
|
132
|
+
|
|
124
133
|
function httpError(relay, status, body) {
|
|
125
134
|
const remoteError = isPlainObject(body) && toText(body.error) ? toText(body.error).slice(0, REMOTE_ERROR_TEXT_MAX) : null;
|
|
126
135
|
const remoteCode = isPlainObject(body) && toText(body.code) ? toText(body.code).slice(0, 100) : null;
|