@oar-sh/oar 0.9.14 → 0.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
|
@@ -93,18 +93,22 @@ function excerpt(value) {
|
|
|
93
93
|
const APPROVAL_VERBS = Object.freeze({
|
|
94
94
|
send: 'send a prompt',
|
|
95
95
|
create_session: 'start a new session',
|
|
96
|
+
update_session: 'change the settings of a session',
|
|
96
97
|
answer_question: 'answer a question',
|
|
97
98
|
stop: 'stop the running turn',
|
|
98
99
|
archive: 'archive a session',
|
|
100
|
+
schedule: 'schedule a prompt',
|
|
101
|
+
ask: 'ask you a question on behalf of another session',
|
|
99
102
|
});
|
|
100
103
|
|
|
101
104
|
/**
|
|
102
105
|
* How the remote turn would run, as far as this relay knows it: the mode (the
|
|
103
106
|
* requested one, else the calling session's, which the dispatcher passes on),
|
|
104
|
-
* the provider a new session gets (requested, else the caller's),
|
|
105
|
-
*
|
|
107
|
+
* the provider a new session gets (requested, else the caller's), the model
|
|
108
|
+
* and working directory when the agent asked for them, and the session group
|
|
109
|
+
* a new session goes into (`group`, as the target resolved it).
|
|
106
110
|
*/
|
|
107
|
-
function describeRemoteTurn(action, args, caller) {
|
|
111
|
+
function describeRemoteTurn(action, args, caller, group = null) {
|
|
108
112
|
const parts = [];
|
|
109
113
|
const mode = toText(args.mode).toLowerCase() || toText(caller?.mode).toLowerCase();
|
|
110
114
|
if (mode) parts.push(`Mode: ${mode}`);
|
|
@@ -116,6 +120,26 @@ function describeRemoteTurn(action, args, caller) {
|
|
|
116
120
|
}
|
|
117
121
|
if (toText(args.model)) parts.push(`Model: ${excerpt(args.model)}`);
|
|
118
122
|
if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)}`);
|
|
123
|
+
if (action === 'create_session') {
|
|
124
|
+
const groupName = toText(group?.name) || toText(args.group);
|
|
125
|
+
if (groupName) parts.push(`Group: ${excerpt(groupName)}${toText(group?.id) ? ` (${excerpt(group.id)})` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
return parts.join(' · ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What an update_session sets, for the card: `Model: … · Effort: … · Group: none`. */
|
|
131
|
+
function describeSettingsChange(args) {
|
|
132
|
+
const parts = [];
|
|
133
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(args || {}, key);
|
|
134
|
+
if (toText(args.model)) parts.push(`Model: ${excerpt(args.model)}`);
|
|
135
|
+
if (toText(args.effort)) parts.push(`Effort: ${excerpt(args.effort)}`);
|
|
136
|
+
if (toText(args.mode)) parts.push(`Mode: ${excerpt(args.mode)}`);
|
|
137
|
+
if (has('auto_compact_window')) parts.push(`Auto-compact window: ${excerpt(args.auto_compact_window)}`);
|
|
138
|
+
if (toText(args.title)) parts.push(`Title: “${excerpt(args.title)}”`);
|
|
139
|
+
if (has('group')) parts.push(`Group: ${args.group === null ? 'none' : excerpt(args.group)}`);
|
|
140
|
+
if (typeof args.thinking_enabled === 'boolean') parts.push(`Thinking: ${args.thinking_enabled ? 'on' : 'off'}`);
|
|
141
|
+
if (toText(args.thinking_display)) parts.push(`Thinking text: ${toText(args.thinking_display) === 'omitted' ? 'hidden' : 'visible'}`);
|
|
142
|
+
if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)} (its CLI restarts there)`);
|
|
119
143
|
return parts.join(' · ');
|
|
120
144
|
}
|
|
121
145
|
|
|
@@ -160,7 +184,8 @@ function formatWatchApprovalPrompt(watch) {
|
|
|
160
184
|
}
|
|
161
185
|
const lines = [];
|
|
162
186
|
if (watch.all) {
|
|
163
|
-
|
|
187
|
+
const excluded = Number(watch.excludeCount) || 0;
|
|
188
|
+
lines.push(`This agent wants to watch ALL sessions of ${where}, including ones created later${excluded ? ` (except ${excluded} it leaves out)` : ''}, and be woken by their events.`);
|
|
164
189
|
} else {
|
|
165
190
|
const sessions = (Array.isArray(watch.sessions) ? watch.sessions : []).slice(0, 10)
|
|
166
191
|
.map((session) => (toText(session?.title) ? `“${excerpt(session.title)}” (${shortId(session.id)})` : shortId(session?.id)));
|
|
@@ -168,18 +193,34 @@ function formatWatchApprovalPrompt(watch) {
|
|
|
168
193
|
lines.push(`This agent wants to watch ${sessions.join(', ')}${more > 0 ? ` and ${more} more` : ''}${relayName ? ` on ${where}` : ''} and be woken by their events.`);
|
|
169
194
|
}
|
|
170
195
|
const events = (Array.isArray(watch.events) ? watch.events : []).join(', ');
|
|
171
|
-
const
|
|
196
|
+
const quiet = watch.quietHours && typeof watch.quietHours === 'object' ? watch.quietHours : null;
|
|
197
|
+
const extras = [
|
|
198
|
+
watch.once ? 'once' : '',
|
|
199
|
+
Number(watch.heartbeatMinutes) > 0 ? `heartbeat every ${watch.heartbeatMinutes} min` : '',
|
|
200
|
+
Number(watch.digestMinutes) > 0 ? `digest every ${watch.digestMinutes} min` : '',
|
|
201
|
+
quiet ? `quiet ${excerpt(`${toText(quiet.from)}–${toText(quiet.to)} ${toText(quiet.tz)}`)}` : '',
|
|
202
|
+
].filter(Boolean);
|
|
172
203
|
if (events) lines.push(`Events: ${events}${extras.length ? ` · ${extras.join(' · ')}` : ''}`);
|
|
173
204
|
return lines.join('\n\n');
|
|
174
205
|
}
|
|
175
206
|
|
|
176
|
-
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0, localSessions = false, watch = null } = {}) {
|
|
207
|
+
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0, localSessions = false, watch = null, group = null, sessionSettings = null } = {}) {
|
|
177
208
|
if (watch && typeof watch === 'object') return formatWatchApprovalPrompt(watch);
|
|
178
209
|
const local = relay?.local === true;
|
|
179
210
|
const name = toText(relay?.name) || 'a remote relay';
|
|
180
211
|
const where = local ? 'this relay' : `relay "${name}"`;
|
|
181
212
|
const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
|
|
182
213
|
const target = args.session ? ` (session ${shortId(args.session)})` : '';
|
|
214
|
+
// The once-per-conversation card for changing sessions the calling conversation did not start.
|
|
215
|
+
if (sessionSettings && typeof sessionSettings === 'object') {
|
|
216
|
+
const title = toText(sessionSettings.title) ? `“${excerpt(sessionSettings.title)}” ` : '';
|
|
217
|
+
const lines = [
|
|
218
|
+
`This agent wants to change the settings of sessions on ${where} that its conversation did not start (yours or other agents'), first ${title}(${shortId(sessionSettings.session || args.session)}).`,
|
|
219
|
+
];
|
|
220
|
+
const details = describeSettingsChange(args);
|
|
221
|
+
if (details) lines.push(details);
|
|
222
|
+
return lines.join('\n\n');
|
|
223
|
+
}
|
|
183
224
|
const lines = [];
|
|
184
225
|
// The loop guard's card: agents that prompt each other without the user.
|
|
185
226
|
if (agentPrompts > 0) {
|
|
@@ -194,13 +235,34 @@ export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, call
|
|
|
194
235
|
lines.push(`Allow the agent to ${verb} on ${where}${target}?`);
|
|
195
236
|
}
|
|
196
237
|
if (action === 'send' || action === 'create_session') {
|
|
197
|
-
const details = describeRemoteTurn(action, args, caller);
|
|
238
|
+
const details = describeRemoteTurn(action, args, caller, group);
|
|
198
239
|
if (details) lines.push(details);
|
|
199
240
|
const text = excerpt(args.text);
|
|
200
241
|
if (text) lines.push(`“${text}”`);
|
|
201
242
|
} else if (action === 'answer_question') {
|
|
202
243
|
const answer = excerpt([...(Array.isArray(args.choices) ? args.choices : []), args.answer].filter(Boolean).join(', '));
|
|
203
244
|
if (answer) lines.push(`Answer: “${answer}”`);
|
|
245
|
+
} else if (action === 'update_session') {
|
|
246
|
+
const details = describeSettingsChange(args);
|
|
247
|
+
if (details) lines.push(details);
|
|
248
|
+
} else if (action === 'schedule') {
|
|
249
|
+
// A schedule for another session: when it fires, and the words it sends.
|
|
250
|
+
const every = Number(args.every_minutes);
|
|
251
|
+
const when = [
|
|
252
|
+
toText(args.title) ? `“${excerpt(args.title)}”` : '',
|
|
253
|
+
toText(args.at) ? `At: ${excerpt(args.at)}` : '',
|
|
254
|
+
every > 0 ? `Every ${every} min${args.once === true ? ', once' : ''}` : '',
|
|
255
|
+
].filter(Boolean).join(' · ');
|
|
256
|
+
if (when) lines.push(when);
|
|
257
|
+
const text = excerpt(args.text);
|
|
258
|
+
if (text) lines.push(`“${text}”`);
|
|
259
|
+
} else if (action === 'ask') {
|
|
260
|
+
// An ask on behalf of another session: its card shows there, and the
|
|
261
|
+
// answer wakes that session.
|
|
262
|
+
const question = excerpt(args.question);
|
|
263
|
+
if (question) lines.push(`“${question}”`);
|
|
264
|
+
const options = (Array.isArray(args.options) ? args.options : []).map(excerpt).filter(Boolean);
|
|
265
|
+
if (options.length) lines.push(`Options: ${options.join(' · ')}`);
|
|
204
266
|
}
|
|
205
267
|
return lines.join('\n\n');
|
|
206
268
|
}
|
|
@@ -303,7 +365,8 @@ export function createRemoteRelayCallerContext({
|
|
|
303
365
|
* the turn it continues); without a turn (an off-turn call) the latest user
|
|
304
366
|
* message decides.
|
|
305
367
|
* `originRelayIds` are the relays (their instance ids) those prompts came
|
|
306
|
-
* from: empty when the agent acts on the user's words alone.
|
|
368
|
+
* from: empty when the agent acts on the user's words alone. `origin` is
|
|
369
|
+
* the one among them with the most hops (null for the user's words).
|
|
307
370
|
*/
|
|
308
371
|
function getCallerContext(conversationId) {
|
|
309
372
|
const id = toText(conversationId);
|
|
@@ -316,6 +379,8 @@ export function createRemoteRelayCallerContext({
|
|
|
316
379
|
: [messageOrigin(getLatestUserMessage.get(id)?.id)]).filter(Boolean);
|
|
317
380
|
const hops = Math.max(0, ...origins.map(originHops));
|
|
318
381
|
const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
|
|
382
|
+
// The prompt with the most hops: what a schedule of this turn's passes on.
|
|
383
|
+
const origin = origins.reduce((best, entry) => (!best || originHops(entry) > originHops(best) ? entry : best), null);
|
|
319
384
|
return {
|
|
320
385
|
conversationId: id,
|
|
321
386
|
title: toText(conversation?.title),
|
|
@@ -328,6 +393,7 @@ export function createRemoteRelayCallerContext({
|
|
|
328
393
|
userMessageId: live?.id || null,
|
|
329
394
|
hops,
|
|
330
395
|
originRelayIds,
|
|
396
|
+
origin,
|
|
331
397
|
};
|
|
332
398
|
}
|
|
333
399
|
|
|
@@ -353,26 +419,27 @@ export function createRemoteRelayCallerContext({
|
|
|
353
419
|
* that, in any relay mode.
|
|
354
420
|
* `localSessions` makes it the card for starting sessions on this relay
|
|
355
421
|
* itself, asked once per conversation (the dispatcher remembers the Allow).
|
|
422
|
+
* `group` (`{ id, name }`) is the session group a create_session names, as
|
|
423
|
+
* the target resolved it; the card says where the session goes.
|
|
424
|
+
* `sessionSettings` (`{ session, title, changed }`) makes it the card for an
|
|
425
|
+
* update_session on a session the calling conversation did not start, asked once per
|
|
426
|
+
* conversation and relay (the dispatcher remembers the Allow).
|
|
356
427
|
*/
|
|
357
|
-
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0, localSessions = false, watch = null } = {}) {
|
|
428
|
+
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0, localSessions = false, watch = null, group = null, sessionSettings = null } = {}) {
|
|
358
429
|
const rowId = toText(callerContext?.processingRowId);
|
|
359
430
|
const row = rowId ? getQueueRow.get(rowId) : null;
|
|
360
431
|
if (!row || row.status !== 'processing') {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
: (localSessions
|
|
367
|
-
? 'Starting sessions on this relay needs the user\'s approval once per conversation, but the conversation has no running turn to ask on.'
|
|
368
|
-
: 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.'),
|
|
369
|
-
};
|
|
432
|
+
let error = 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.';
|
|
433
|
+
if (watch) error = 'Watching sessions needs the user\'s approval, but the conversation has no running turn to ask on.';
|
|
434
|
+
else if (localSessions) error = 'Starting sessions on this relay needs the user\'s approval once per conversation, but the conversation has no running turn to ask on.';
|
|
435
|
+
else if (sessionSettings) error = 'Changing a session this conversation did not start needs the owner\'s approval once per conversation, but the conversation has no running turn to ask on.';
|
|
436
|
+
return { approved: false, code: REMOTE_RELAY_ERROR_CODES.noTurn, error };
|
|
370
437
|
}
|
|
371
438
|
|
|
372
439
|
const createdAt = now().toISOString();
|
|
373
440
|
const questionId = String(uuid());
|
|
374
441
|
const relayMode = normalizeMode(row.relay_mode);
|
|
375
|
-
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts, localSessions, watch });
|
|
442
|
+
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts, localSessions, watch, group, sessionSettings });
|
|
376
443
|
const local = relay?.local === true;
|
|
377
444
|
let rationale = `Write actions on ${local ? 'other sessions of this relay' : 'other relays'} need approval in ${relayMode} mode.`;
|
|
378
445
|
if (agentPrompts > 0) {
|
|
@@ -381,6 +448,8 @@ export function createRemoteRelayCallerContext({
|
|
|
381
448
|
: 'Agents on different relays that keep prompting each other need your go from time to time.';
|
|
382
449
|
} else if (localSessions) {
|
|
383
450
|
rationale = 'Allow covers every session this conversation\'s agent starts on this relay; Deny refuses this one.';
|
|
451
|
+
} else if (sessionSettings) {
|
|
452
|
+
rationale = `Allow covers this conversation's later changes to sessions on ${local ? 'this relay' : `relay "${excerpt(relay?.name)}"`} that it did not start; Deny refuses this one. Sessions this conversation started need no approval.`;
|
|
384
453
|
} else if (watch?.kind === 'transfer') {
|
|
385
454
|
rationale = 'The subscriptions keep their settings; event messages are the relay\'s, never yours, and approve nothing.';
|
|
386
455
|
} else if (watch) {
|
|
@@ -397,7 +466,7 @@ export function createRemoteRelayCallerContext({
|
|
|
397
466
|
queueMessageId: row.id,
|
|
398
467
|
conversationId: toText(conversationId) || row.conversation_id,
|
|
399
468
|
relayMode,
|
|
400
|
-
header: watch ? WATCH_HEADER : (localSessions ? LOCAL_SESSIONS_HEADER : APPROVAL_HEADER),
|
|
469
|
+
header: watch ? WATCH_HEADER : (localSessions || (sessionSettings && local) ? LOCAL_SESSIONS_HEADER : APPROVAL_HEADER),
|
|
401
470
|
}, { normalizeRelayMode: normalizeMode, defaultRelayMode });
|
|
402
471
|
questionStatements.insertQuestion.run(
|
|
403
472
|
questionId,
|