@oar-sh/oar 0.9.4 → 0.9.6
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 +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -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/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -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-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +97 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- 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 +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- 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 +97 -4
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- 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/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -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
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The process table, read the way each platform allows, for the tree watch in
|
|
4
|
+
// shared/worker-runtime/process-tree.mjs. Every entry carries the time the
|
|
5
|
+
// process was started: that is what tells it from a later process that was
|
|
6
|
+
// given its pid.
|
|
7
|
+
//
|
|
8
|
+
// - Linux reads `/proc/<pid>/stat`, which needs no process and takes about
|
|
9
|
+
// 3 ms for 160 processes (measured 2026-09-29). The start time is field 22,
|
|
10
|
+
// in clock ticks since boot.
|
|
11
|
+
// - Windows asks PowerShell, through the list the relay's process inspector
|
|
12
|
+
// already reads. It takes about a second, so it is never polled.
|
|
13
|
+
// - Every other platform asks `ps`, whose start time has a resolution of one
|
|
14
|
+
// second.
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import { execFile } from 'child_process';
|
|
18
|
+
|
|
19
|
+
import { createSessionWorkerProcessInspector } from './session-worker-process-service.mjs';
|
|
20
|
+
|
|
21
|
+
const PS_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One `/proc/<pid>/stat`. The name (field 2) is in parentheses and may hold
|
|
25
|
+
* spaces and parentheses of its own, so the fields after it are counted from
|
|
26
|
+
* the LAST closing one. A process that has ended and only waits to be
|
|
27
|
+
* collected (`Z`, `X`) is not running any more.
|
|
28
|
+
*/
|
|
29
|
+
export function parseProcStat(text) {
|
|
30
|
+
const raw = String(text || '');
|
|
31
|
+
const open = raw.indexOf('(');
|
|
32
|
+
const close = raw.lastIndexOf(')');
|
|
33
|
+
if (open < 1 || close < open) return null;
|
|
34
|
+
const processId = Number.parseInt(raw.slice(0, open), 10);
|
|
35
|
+
const fields = raw.slice(close + 2).trim().split(/\s+/);
|
|
36
|
+
const state = fields[0] || '';
|
|
37
|
+
const createdAt = Number(fields[19]);
|
|
38
|
+
if (!(processId > 0) || state === 'Z' || state === 'X' || !(createdAt > 0)) return null;
|
|
39
|
+
return {
|
|
40
|
+
processId,
|
|
41
|
+
parentProcessId: Number.parseInt(fields[1], 10) || 0,
|
|
42
|
+
name: raw.slice(open + 1, close),
|
|
43
|
+
createdAt,
|
|
44
|
+
// The runtime starts a command in a session of its own.
|
|
45
|
+
sessionLeader: Number.parseInt(fields[3], 10) === processId,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One line of `ps -o pid=,ppid=,stat=,lstart=,args=`. */
|
|
50
|
+
export function parsePsLine(line) {
|
|
51
|
+
const match = String(line || '').match(
|
|
52
|
+
/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s*(.*)$/,
|
|
53
|
+
);
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
const createdAt = Date.parse(match[4]);
|
|
56
|
+
if (/^[ZX]/.test(match[3]) || !(createdAt > 0)) return null;
|
|
57
|
+
return {
|
|
58
|
+
processId: Number.parseInt(match[1], 10),
|
|
59
|
+
parentProcessId: Number.parseInt(match[2], 10) || 0,
|
|
60
|
+
createdAt,
|
|
61
|
+
commandLine: match[5],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createProcessLister({
|
|
66
|
+
platform = process.platform,
|
|
67
|
+
readdirSyncImpl = fs.readdirSync,
|
|
68
|
+
readFileSyncImpl = fs.readFileSync,
|
|
69
|
+
execFileImpl = execFile,
|
|
70
|
+
inspector = null,
|
|
71
|
+
} = {}) {
|
|
72
|
+
if (platform === 'win32') {
|
|
73
|
+
const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl });
|
|
74
|
+
return {
|
|
75
|
+
// A child keeps the pid of its parent after the parent has ended.
|
|
76
|
+
orphansKeepParent: true,
|
|
77
|
+
list: () => windows.readWindowsProcessSnapshot(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (platform === 'linux') {
|
|
82
|
+
const read = (pid) => {
|
|
83
|
+
try {
|
|
84
|
+
return parseProcStat(readFileSyncImpl(`/proc/${pid}/stat`, 'utf8'));
|
|
85
|
+
} catch {
|
|
86
|
+
// It ended between the directory listing and the read.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
orphansKeepParent: false,
|
|
92
|
+
list: () => readdirSyncImpl('/proc')
|
|
93
|
+
.filter((name) => /^\d+$/.test(String(name)))
|
|
94
|
+
.map((name) => read(name))
|
|
95
|
+
.filter(Boolean),
|
|
96
|
+
read,
|
|
97
|
+
describe(pid) {
|
|
98
|
+
try {
|
|
99
|
+
return String(readFileSyncImpl(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ').trim();
|
|
100
|
+
} catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
orphansKeepParent: false,
|
|
109
|
+
list: () => new Promise((resolve, reject) => {
|
|
110
|
+
execFileImpl(
|
|
111
|
+
'ps',
|
|
112
|
+
['-axww', '-o', 'pid=,ppid=,stat=,lstart=,args='],
|
|
113
|
+
// `lstart` is parsed as a date: keep it in the one language that is.
|
|
114
|
+
{ timeout: PS_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } },
|
|
115
|
+
(error, stdout) => {
|
|
116
|
+
if (error) reject(error);
|
|
117
|
+
else resolve(String(stdout || '').split(/\r?\n/).map(parsePsLine).filter(Boolean));
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -120,11 +120,16 @@ function describeRemoteTurn(action, args, caller) {
|
|
|
120
120
|
* run, then the prompt (or the answer) it wants to send, so the user decides
|
|
121
121
|
* on the actual words. `caller` is the calling session's context.
|
|
122
122
|
*/
|
|
123
|
-
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
|
|
123
|
+
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0 } = {}) {
|
|
124
124
|
const name = toText(relay?.name) || 'a remote relay';
|
|
125
125
|
const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
|
|
126
126
|
const target = args.session ? ` (session ${shortId(args.session)})` : '';
|
|
127
|
-
const lines = [
|
|
127
|
+
const lines = [];
|
|
128
|
+
// The loop guard's card: agents that prompt each other without the user.
|
|
129
|
+
if (agentPrompts > 0) {
|
|
130
|
+
lines.push(`The agent has sent ${agentPrompts} prompts to agents on other relays since your last message. Let it go on?`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(`Allow the agent to ${verb} on relay "${name}"${target}?`);
|
|
128
133
|
if (action === 'send' || action === 'create_session') {
|
|
129
134
|
const details = describeRemoteTurn(action, args, caller);
|
|
130
135
|
if (details) lines.push(details);
|
|
@@ -155,10 +160,12 @@ export function createRemoteRelayCallerContext({
|
|
|
155
160
|
questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
|
|
156
161
|
} = {}) {
|
|
157
162
|
const questionStatements = questions || createQuestionRepository(db);
|
|
158
|
-
const getConversation = db.prepare(`
|
|
163
|
+
const getConversation = db.prepare(`
|
|
164
|
+
SELECT id, title, preferred_relay_mode, preferred_model, preferred_reasoning_effort FROM conversations WHERE id = ?
|
|
165
|
+
`);
|
|
159
166
|
const getRuntimeSession = db.prepare(`SELECT provider_type, provider_model, model FROM runtime_sessions WHERE conversation_id = ?`);
|
|
160
167
|
const listProcessingRows = db.prepare(`
|
|
161
|
-
SELECT id, kind, relay_mode, model, attempt_id, owner_sdk_session_id, timestamp, processing_at
|
|
168
|
+
SELECT id, kind, relay_mode, model, reasoning_effort, attempt_id, owner_sdk_session_id, timestamp, processing_at
|
|
162
169
|
FROM queue
|
|
163
170
|
WHERE conversation_id = ? AND status = 'processing'
|
|
164
171
|
ORDER BY COALESCE(processing_at, timestamp) ASC
|
|
@@ -196,35 +203,44 @@ export function createRemoteRelayCallerContext({
|
|
|
196
203
|
return rows.find((row) => row.id === liveId) || rows[0];
|
|
197
204
|
}
|
|
198
205
|
|
|
199
|
-
function
|
|
200
|
-
|
|
206
|
+
function messageOrigin(messageId) {
|
|
207
|
+
return (messageId ? repository?.getMessageOrigin?.(messageId) : null) || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function originHops(origin) {
|
|
201
211
|
const hops = Number(origin?.hops);
|
|
202
212
|
return Number.isInteger(hops) && hops >= 0 ? hops : 0;
|
|
203
213
|
}
|
|
204
214
|
|
|
205
215
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* turn whose background work it
|
|
216
|
+
* Where a turn's prompt came from (null for a human's): the origin of its
|
|
217
|
+
* own user message, or for a continuation row (no message of its own) that
|
|
218
|
+
* of the latest user message before it, the turn whose background work it
|
|
219
|
+
* continues.
|
|
209
220
|
*/
|
|
210
|
-
function
|
|
211
|
-
if (toText(row.kind).toLowerCase() !== 'continuation') return
|
|
221
|
+
function rowOrigin(conversationId, row) {
|
|
222
|
+
if (toText(row.kind).toLowerCase() !== 'continuation') return messageOrigin(row.id);
|
|
212
223
|
const at = toText(row.timestamp) || toText(row.processing_at);
|
|
213
224
|
const message = at
|
|
214
225
|
? getLatestUserMessageAtOrBefore.get(conversationId, at)
|
|
215
226
|
: getLatestUserMessage.get(conversationId);
|
|
216
|
-
return
|
|
227
|
+
return messageOrigin(message?.id);
|
|
217
228
|
}
|
|
218
229
|
|
|
219
230
|
/**
|
|
220
|
-
* `{ conversationId, title, provider, model, mode, processingRowId,
|
|
221
|
-
* attemptId, userMessageId, hops }` for the conversation
|
|
231
|
+
* `{ conversationId, title, provider, model, effort, mode, processingRowId,
|
|
232
|
+
* attemptId, userMessageId, hops, originRelayIds }` for the conversation
|
|
233
|
+
* making the call.
|
|
234
|
+
* `effort` is the reasoning effort of the live turn, else the one the
|
|
235
|
+
* conversation prefers ('' when neither is known).
|
|
222
236
|
* `hops` is how many relays the current turn's prompt already crossed: 0
|
|
223
237
|
* for a human's message. A steered message folded into the live turn is
|
|
224
238
|
* part of what the agent is acting on, so the highest count among the
|
|
225
239
|
* conversation's processing rows wins (a background continuation counts as
|
|
226
240
|
* the turn it continues); without a turn (an off-turn call) the latest user
|
|
227
241
|
* message decides.
|
|
242
|
+
* `originRelayIds` are the relays (their instance ids) those prompts came
|
|
243
|
+
* from: empty when the agent acts on the user's words alone.
|
|
228
244
|
*/
|
|
229
245
|
function getCallerContext(conversationId) {
|
|
230
246
|
const id = toText(conversationId);
|
|
@@ -232,19 +248,23 @@ export function createRemoteRelayCallerContext({
|
|
|
232
248
|
const runtime = getRuntimeSession.get(id) || null;
|
|
233
249
|
const rows = listProcessingRows.all(id);
|
|
234
250
|
const live = liveTurnRow(id, rows);
|
|
235
|
-
const
|
|
236
|
-
?
|
|
237
|
-
:
|
|
251
|
+
const origins = (rows.length
|
|
252
|
+
? rows.map((row) => rowOrigin(id, row))
|
|
253
|
+
: [messageOrigin(getLatestUserMessage.get(id)?.id)]).filter(Boolean);
|
|
254
|
+
const hops = Math.max(0, ...origins.map(originHops));
|
|
255
|
+
const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
|
|
238
256
|
return {
|
|
239
257
|
conversationId: id,
|
|
240
258
|
title: toText(conversation?.title),
|
|
241
259
|
provider: toText(runtime?.provider_type).toLowerCase() || 'github',
|
|
242
260
|
model: toText(live?.model) || toText(runtime?.provider_model) || toText(runtime?.model) || toText(conversation?.preferred_model) || '',
|
|
261
|
+
effort: (toText(live?.reasoning_effort) || toText(conversation?.preferred_reasoning_effort)).toLowerCase(),
|
|
243
262
|
mode: normalizeMode(live?.relay_mode || conversation?.preferred_relay_mode),
|
|
244
263
|
processingRowId: live?.id || null,
|
|
245
264
|
attemptId: toText(live?.attempt_id) || null,
|
|
246
265
|
userMessageId: live?.id || null,
|
|
247
266
|
hops,
|
|
267
|
+
originRelayIds,
|
|
248
268
|
};
|
|
249
269
|
}
|
|
250
270
|
|
|
@@ -265,8 +285,11 @@ export function createRemoteRelayCallerContext({
|
|
|
265
285
|
* denied, timed out, cancelled with the turn, or no turn to ask on (fails
|
|
266
286
|
* closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
|
|
267
287
|
* lives (8 h by default); `signal` withdraws the card early.
|
|
288
|
+
* `agentPrompts` (the loop guard) is how many prompts the agent has sent to
|
|
289
|
+
* other relays' agents since the user last wrote; the card then asks for
|
|
290
|
+
* that, in any relay mode.
|
|
268
291
|
*/
|
|
269
|
-
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
|
|
292
|
+
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0 } = {}) {
|
|
270
293
|
const rowId = toText(callerContext?.processingRowId);
|
|
271
294
|
const row = rowId ? getQueueRow.get(rowId) : null;
|
|
272
295
|
if (!row || row.status !== 'processing') {
|
|
@@ -280,10 +303,12 @@ export function createRemoteRelayCallerContext({
|
|
|
280
303
|
const createdAt = now().toISOString();
|
|
281
304
|
const questionId = String(uuid());
|
|
282
305
|
const relayMode = normalizeMode(row.relay_mode);
|
|
283
|
-
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
|
|
306
|
+
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts });
|
|
284
307
|
const context = sanitizeRelayQuestionContext({
|
|
285
308
|
source: REMOTE_RELAY_APPROVAL_SOURCE,
|
|
286
|
-
rationale:
|
|
309
|
+
rationale: agentPrompts > 0
|
|
310
|
+
? 'Agents on different relays that keep prompting each other need your go from time to time.'
|
|
311
|
+
: `Write actions on other relays need approval in ${relayMode} mode.`,
|
|
287
312
|
queueMessageId: row.id,
|
|
288
313
|
conversationId: toText(conversationId) || row.conversation_id,
|
|
289
314
|
relayMode,
|