@oar-sh/oar 0.9.15 → 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 +85 -0
- package/README.md +9 -9
- package/package.json +1 -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/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 +568 -42
- 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/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/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
|
@@ -15,12 +15,21 @@
|
|
|
15
15
|
// the stdio MCP server and (for the header/URL helpers) the browser.
|
|
16
16
|
|
|
17
17
|
import { isValidBranchName, normalizeGitHubRepoUrl } from './claude-cloud/repo-url.mjs';
|
|
18
|
+
import { AUTO_COMPACT_WINDOW_STOPS } from './auto-compact-window.mjs';
|
|
19
|
+
import { THINKING_DISPLAY_MODES } from './claude-thinking.mjs';
|
|
18
20
|
import {
|
|
19
21
|
RELAY_EVENT_LIMITS,
|
|
20
22
|
RELAY_EVENT_SUBSCRIBABLE_TYPES,
|
|
21
23
|
compileRelayEventMatch,
|
|
22
24
|
isRelayLevelOnly,
|
|
25
|
+
normalizeRelayEventQuietHours,
|
|
23
26
|
} from './relay-events.mjs';
|
|
27
|
+
import {
|
|
28
|
+
RELAY_SCHEDULE_LIMITS,
|
|
29
|
+
normalizeRelayScheduleAt,
|
|
30
|
+
normalizeRelayScheduleTitle,
|
|
31
|
+
} from './relay-schedules.mjs';
|
|
32
|
+
import { RELAY_ASK_LIMITS, RELAY_ASK_URGENCIES, normalizeRelayAskInput } from './relay-asks.mjs';
|
|
24
33
|
|
|
25
34
|
export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
|
|
26
35
|
|
|
@@ -42,6 +51,7 @@ export const REMOTE_RELAY_ACTIONS = Object.freeze([
|
|
|
42
51
|
'wait',
|
|
43
52
|
'send',
|
|
44
53
|
'create_session',
|
|
54
|
+
'update_session',
|
|
45
55
|
'answer_question',
|
|
46
56
|
'stop',
|
|
47
57
|
'archive',
|
|
@@ -49,12 +59,27 @@ export const REMOTE_RELAY_ACTIONS = Object.freeze([
|
|
|
49
59
|
'unsubscribe',
|
|
50
60
|
'list_subscriptions',
|
|
51
61
|
'transfer_subscriptions',
|
|
62
|
+
'schedule',
|
|
63
|
+
'list_schedules',
|
|
64
|
+
'unschedule',
|
|
65
|
+
'ask',
|
|
66
|
+
'list_questions',
|
|
67
|
+
'withdraw',
|
|
52
68
|
]);
|
|
53
69
|
|
|
54
70
|
// Event subscriptions (shared/relay-events.mjs): `relay` may be left out and
|
|
55
71
|
// means "this".
|
|
56
72
|
export const REMOTE_RELAY_EVENT_ACTIONS = Object.freeze(['subscribe', 'unsubscribe', 'list_subscriptions', 'transfer_subscriptions']);
|
|
57
73
|
|
|
74
|
+
// Schedules (shared/relay-schedules.mjs): kept by the relay the agent runs on,
|
|
75
|
+
// so `relay` may be left out and means "this"; a paired relay is refused.
|
|
76
|
+
export const REMOTE_RELAY_SCHEDULE_ACTIONS = Object.freeze(['schedule', 'list_schedules', 'unschedule']);
|
|
77
|
+
|
|
78
|
+
// Questions that don't block (shared/relay-asks.mjs): `relay` may be left out
|
|
79
|
+
// and means "this"; `session` (default: the caller's own conversation) names
|
|
80
|
+
// another session there.
|
|
81
|
+
export const REMOTE_RELAY_ASK_ACTIONS = Object.freeze(['ask', 'list_questions', 'withdraw']);
|
|
82
|
+
|
|
58
83
|
export const REMOTE_RELAY_PERMISSIONS = Object.freeze(['read', 'prompt', 'full']);
|
|
59
84
|
export const REMOTE_RELAY_DEFAULT_PERMISSION = 'full';
|
|
60
85
|
|
|
@@ -69,6 +94,7 @@ export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
|
|
|
69
94
|
wait: 'read',
|
|
70
95
|
send: 'prompt',
|
|
71
96
|
create_session: 'full',
|
|
97
|
+
update_session: 'full',
|
|
72
98
|
answer_question: 'full',
|
|
73
99
|
stop: 'full',
|
|
74
100
|
archive: 'full',
|
|
@@ -76,11 +102,51 @@ export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
|
|
|
76
102
|
unsubscribe: 'read',
|
|
77
103
|
list_subscriptions: 'read',
|
|
78
104
|
transfer_subscriptions: 'read',
|
|
105
|
+
// A schedule for another session prompts it: 'prompt'. For the caller's
|
|
106
|
+
// own conversation it needs only 'read' (remoteRelayActionPermission).
|
|
107
|
+
schedule: 'prompt',
|
|
108
|
+
list_schedules: 'read',
|
|
109
|
+
unschedule: 'read',
|
|
110
|
+
// An ask on behalf of another session puts a card there and wakes it with
|
|
111
|
+
// the answer: 'prompt'. For the caller's own conversation 'read'.
|
|
112
|
+
ask: 'prompt',
|
|
113
|
+
list_questions: 'read',
|
|
114
|
+
withdraw: 'read',
|
|
79
115
|
});
|
|
80
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The permission one call needs: the action's, except a schedule or an ask
|
|
119
|
+
* for the caller's own conversation, which prompts nobody else ('read').
|
|
120
|
+
*/
|
|
121
|
+
export function remoteRelayActionPermission(action, { ownSession = false } = {}) {
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(REMOTE_RELAY_ACTION_PERMISSION, action)) return undefined;
|
|
123
|
+
if ((action === 'schedule' || action === 'ask') && ownSession) return 'read';
|
|
124
|
+
return REMOTE_RELAY_ACTION_PERMISSION[action];
|
|
125
|
+
}
|
|
126
|
+
|
|
81
127
|
// Actions that change something on the remote relay: they need approval when
|
|
82
128
|
// the calling session runs in ask or plan mode.
|
|
83
|
-
export const REMOTE_RELAY_WRITE_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question', 'stop', 'archive']);
|
|
129
|
+
export const REMOTE_RELAY_WRITE_ACTIONS = Object.freeze(['send', 'create_session', 'update_session', 'answer_question', 'stop', 'archive']);
|
|
130
|
+
|
|
131
|
+
// What update_session may change on a session (at least one per call). The
|
|
132
|
+
// owner's "agents in this conversation may start sessions" is not among them:
|
|
133
|
+
// an agent could grant it to itself.
|
|
134
|
+
export const REMOTE_RELAY_SESSION_SETTINGS = Object.freeze([
|
|
135
|
+
'model',
|
|
136
|
+
'effort',
|
|
137
|
+
'mode',
|
|
138
|
+
'auto_compact_window',
|
|
139
|
+
'title',
|
|
140
|
+
'group',
|
|
141
|
+
'thinking_enabled',
|
|
142
|
+
'thinking_display',
|
|
143
|
+
// The working directory: the session's CLI restarts there.
|
|
144
|
+
'cwd',
|
|
145
|
+
]);
|
|
146
|
+
export const REMOTE_RELAY_THINKING_DISPLAYS = THINKING_DISPLAY_MODES;
|
|
147
|
+
// update_session's `group` value that takes a session out of its group (JSON
|
|
148
|
+
// null does too, for providers that can send it).
|
|
149
|
+
export const REMOTE_RELAY_NO_GROUP = 'none';
|
|
84
150
|
|
|
85
151
|
// The write actions that carry an agent's words to another relay's agent.
|
|
86
152
|
export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question']);
|
|
@@ -161,6 +227,8 @@ export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
|
|
|
161
227
|
providerUnavailable: 'REMOTE_RELAY_PROVIDER_UNAVAILABLE',
|
|
162
228
|
// create_session or send named a model the provider's list there lacks.
|
|
163
229
|
unknownModel: 'REMOTE_RELAY_UNKNOWN_MODEL',
|
|
230
|
+
// create_session named a session group the target does not have (or several).
|
|
231
|
+
unknownGroup: 'REMOTE_RELAY_UNKNOWN_GROUP',
|
|
164
232
|
busy: 'REMOTE_RELAY_BUSY',
|
|
165
233
|
noTurn: 'REMOTE_RELAY_NO_ACTIVE_TURN',
|
|
166
234
|
// The local target (this relay itself):
|
|
@@ -170,10 +238,17 @@ export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
|
|
|
170
238
|
nestedSession: 'REMOTE_RELAY_NESTED_SESSION',
|
|
171
239
|
// the conversation already has `localActiveSessions` created sessions at work,
|
|
172
240
|
sessionLimit: 'REMOTE_RELAY_SESSION_LIMIT',
|
|
173
|
-
// and an agent tried to prompt, stop, archive, wait for or
|
|
241
|
+
// and an agent tried to prompt, stop, archive, wait for, answer or update itself.
|
|
174
242
|
ownSession: 'REMOTE_RELAY_OWN_SESSION',
|
|
243
|
+
// update_session on a session the owner started: the owner did not allow
|
|
244
|
+
// this conversation to change such sessions there (or could not be asked).
|
|
245
|
+
permission: 'REMOTE_RELAY_PERMISSION',
|
|
175
246
|
// a conversation already has the most event subscriptions it may hold.
|
|
176
247
|
subscriptionLimit: 'REMOTE_RELAY_SUBSCRIPTION_LIMIT',
|
|
248
|
+
// a conversation already has the most schedules it may hold or set.
|
|
249
|
+
scheduleLimit: 'REMOTE_RELAY_SCHEDULE_LIMIT',
|
|
250
|
+
// a conversation (or the session it asks for) already has the most pending asks.
|
|
251
|
+
questionLimit: 'REMOTE_RELAY_QUESTION_LIMIT',
|
|
177
252
|
internal: 'REMOTE_RELAY_INTERNAL',
|
|
178
253
|
// Prefix: the remote answered with this HTTP status, e.g. REMOTE_RELAY_HTTP_409.
|
|
179
254
|
httpPrefix: 'REMOTE_RELAY_HTTP_',
|
|
@@ -226,10 +301,21 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
226
301
|
+ `${REMOTE_RELAY_LIMITS.localActiveSessions} sessions that work at the same time (any provider relay_info lists: local ones `
|
|
227
302
|
+ 'with cwd, provider "claude-cloud" with repo and an optional branch), then wait for and read '
|
|
228
303
|
+ 'each one. The first create_session here shows the user an approval card once per '
|
|
229
|
-
+ 'conversation. A session
|
|
304
|
+
+ 'conversation. A session an agent created cannot create sessions itself unless the owner allowed '
|
|
305
|
+
+ 'it for that session (list_sessions and read_session show mayCreateSessions), and every session '
|
|
230
306
|
+ 'runs on the relay owner\'s accounts, so start only what the task needs. Sessions you start '
|
|
231
307
|
+ 'here are grouped under this session in the owner\'s sidebar; list_sessions and read_session '
|
|
232
|
-
+ 'show the group
|
|
308
|
+
+ 'show the group. Agents can name an existing group when they create a session (group: an id '
|
|
309
|
+
+ 'or name relay_info lists, on this relay or a paired one). update_session {session, ...} sets '
|
|
310
|
+
+ 'another session\'s model, effort, mode, auto_compact_window, title, group (an id or name; a name '
|
|
311
|
+
+ 'not there yet makes a new group; "none" takes the session out), thinking and cwd (its CLI restarts '
|
|
312
|
+
+ 'there; not while it works) for its next turns without prompting it, and answers the settings and '
|
|
313
|
+
+ 'what changed. A session this conversation started may be changed freely; any other (the owner\'s, '
|
|
314
|
+
+ 'another agent\'s, the one that started you) needs the owner\'s Allow once per conversation and relay; '
|
|
315
|
+
+ 'your own conversation never. A group the owner made is never removed that way (it may stay empty). A mode '
|
|
316
|
+
+ 'set so lasts until the next send that names another mode or comes from a caller in another mode (a send '
|
|
317
|
+
+ 'without mode passes the caller\'s). Renaming and dissolving groups stay the owner\'s. An argument an action does not '
|
|
318
|
+
+ 'take comes back in ignored; create_session and update_session refuse it and change nothing. A new session '
|
|
233
319
|
+ 'knows nothing of this conversation: write a self-contained prompt, and for '
|
|
234
320
|
+ '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
|
|
235
321
|
+ 'work on and to push. '
|
|
@@ -250,19 +336,25 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
250
336
|
+ 'since its last output, and pass/fail counts when it is a test run; credentials are scrubbed. '
|
|
251
337
|
+ 'Event subscriptions: subscribe to sessions of this relay or of a paired relay (ids, or "all" for every '
|
|
252
338
|
+ 'session there, later ones too) and the relay wakes you with a message when something happens there: '
|
|
253
|
-
+ 'question_asked (with the question and its options
|
|
339
|
+
+ 'question_asked (with the question and its options; it brings question_answered too, when the user answers an ask there), '
|
|
340
|
+
+ 'error (failed turn, usage limit, worker died), '
|
|
254
341
|
+ 'turn_end (with a reply preview), task_failed (a background sub-agent or shell task failed or exited non-zero), '
|
|
255
342
|
+ 'subagent_finished (a background sub-agent ended well; its line says task_finished), '
|
|
256
343
|
+ `stalled (no activity for stalled_minutes, default ${RELAY_EVENT_LIMITS.stalledMinutesDefault}, while work runs), all_idle (nothing runs in the `
|
|
257
344
|
+ 'watched sessions any more); task_started, and task_finished (every task that ended well, shell commands '
|
|
258
345
|
+ 'included: a busy session ends many), only when named in events. relay_restarted (this relay only, only when '
|
|
259
346
|
+ 'named, needs no sessions): the relay runs again, with how it stopped; before you request a relay restart, '
|
|
260
|
-
+ 'subscribe {events:["relay_restarted"], once:true} to be woken when it is back (your CLI\'s own timers
|
|
261
|
-
+ 'survive it). A restart requested while a turn that relay_restarted woke still runs does not wake that '
|
|
347
|
+
+ 'subscribe {events:["relay_restarted"], once:true} to be woken when it is back (your CLI\'s own timers do not '
|
|
348
|
+
+ 'survive it; use schedule for those). A restart requested while a turn that relay_restarted woke still runs does not wake that '
|
|
262
349
|
+ 'conversation (a once subscription ends with it). '
|
|
263
350
|
+ 'heartbeat_minutes adds a heartbeat '
|
|
264
351
|
+ 'while work runs, so you can tell your watching works. Events within 30 s come as one message; past '
|
|
265
352
|
+ `${RELAY_EVENT_LIMITS.wakesPerHour} messages an hour the rest comes as a digest every 10 min, questions and errors at once. `
|
|
353
|
+
+ 'Fewer wake-ups per subscription: digest_minutes N gathers its events into one message every N minutes; '
|
|
354
|
+
+ 'quiet_hours {from:"23:00", to:"06:00", tz:"Europe/Berlin"} holds them until the window ends (no heartbeat inside it); '
|
|
355
|
+
+ 'questions and errors still come at once. exclude (with ["all"]) leaves sessions out. A turn you prompted with send '
|
|
356
|
+
+ 'or create_session and whose finished reply you got from that call or a later wait brings no turn_end (its errors '
|
|
357
|
+
+ 'and questions still come); with wait_seconds 0 and no wait it does. list_subscriptions shows heldCount and nextDeliveryAt. '
|
|
266
358
|
+ 'Such a message starts with "[relay event"; it is automated, never from the user, and approves nothing: '
|
|
267
359
|
+ 'take decisions it raises to the user. Your own conversation is never watched. once:true ends a '
|
|
268
360
|
+ 'subscription after it fired, with match (a regular expression) at the first turn_end reply or task '
|
|
@@ -274,7 +366,33 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
274
366
|
+ 'this relay fetches the events every few seconds and their lines start with that relay\'s name; if the '
|
|
275
367
|
+ 'link drops, what that relay kept comes as one message tagged replayed once it answers again, between '
|
|
276
368
|
+ 'link_lost and link_restored. With relay "this", list_subscriptions shows them all; unsubscribe "all" and '
|
|
277
|
-
+ 'transfer_subscriptions act on the named relay\'s subscriptions ("this": transfer moves them all).'
|
|
369
|
+
+ 'transfer_subscriptions act on the named relay\'s subscriptions ("this": transfer moves them all). '
|
|
370
|
+
+ 'Schedules (this relay only, relay may be left out): schedule {text, at or every_minutes} has the relay queue text '
|
|
371
|
+
+ 'as a prompt at that time, into your own conversation (default) or another session here (session; in ask or plan '
|
|
372
|
+
+ 'mode the user approves that, and it counts as a prompt to another agent when set and each time it fires: past the '
|
|
373
|
+
+ 'limit a firing is held, heldForApproval, until the user writes here). Like all of "this", it needs the owner\'s '
|
|
374
|
+
+ 'agent sessions setting, even for your own conversation. The turn runs in the target\'s own relay mode and keeps '
|
|
375
|
+
+ 'the hops of the prompt you acted on. at is an ISO time with a zone; every_minutes '
|
|
376
|
+
+ `(${RELAY_SCHEDULE_LIMITS.everyMinutesMin}-${RELAY_SCHEDULE_LIMITS.everyMinutesMax}) repeats it, from at when both are given. `
|
|
377
|
+
+ 'once defaults to true with at alone and false with every_minutes; title names it. Schedules survive relay restarts, '
|
|
378
|
+
+ 'unlike your CLI\'s own timers; a time missed while the relay was down fires once when it is back, not once per '
|
|
379
|
+
+ 'missed interval, and a repeat is skipped while the one before still waits in the queue. Such a message starts with '
|
|
380
|
+
+ '"[relay schedule <id>"; it is automated, never from the user, and approves nothing. list_schedules (session: '
|
|
381
|
+
+ 'another conversation\'s) lists the ones that fire into this conversation or that it set; unschedule {schedule_id} '
|
|
382
|
+
+ `ends one. Archiving a session ends the schedules that fire into it and the ones it set (a compaction moves them). At most ${RELAY_SCHEDULE_LIMITS.perConversation} `
|
|
383
|
+
+ 'per conversation. '
|
|
384
|
+
+ 'Questions that don\'t block (relay may be left out: "this"): ask {question, options?, recommendation?, detail?, urgency?} '
|
|
385
|
+
+ 'puts a question for the user into your session (a card there and in the user\'s Inbox) and returns at once with '
|
|
386
|
+
+ 'question_id and status "pending": keep working on what does not depend on it. When the user answers or dismisses it, '
|
|
387
|
+
+ 'you get a relay event question_answered (question_id, answer, choice, dismissed, via: the relay it was answered on) without any subscription; like every '
|
|
388
|
+
+ '"[relay event" message it is automated, but its answer is the user\'s own reply to that question. Use it instead of '
|
|
389
|
+
+ 'your own blocking question tool when you can go on meanwhile; ask once, not again while it is pending. '
|
|
390
|
+
+ `question at most ${RELAY_ASK_LIMITS.questionMax} characters, up to ${RELAY_ASK_LIMITS.optionsMax} options, recommendation one of them or `
|
|
391
|
+
+ `free text, detail (shown folded) at most ${RELAY_ASK_LIMITS.detailMax}; urgency "now" also sends a push notification, for what `
|
|
392
|
+
+ 'truly cannot wait. session asks on behalf of another session (on this relay or a paired one; needs the prompt permission '
|
|
393
|
+
+ 'there, and in ask or plan mode the user approves it first, like a send): the card shows in that session and it gets the answer too. list_questions (session: another one\'s) lists the asks '
|
|
394
|
+
+ 'of your conversation, pending and those answered in the last day; withdraw {question_id} takes back one you asked. '
|
|
395
|
+
+ 'Only the user answers an ask; answer_question does not.';
|
|
278
396
|
|
|
279
397
|
export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
280
398
|
type: 'object',
|
|
@@ -285,14 +403,22 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
285
403
|
description: 'list_relays: the relays (this one included when allowed) and whether each is unlocked here. relay_info: providers, models, '
|
|
286
404
|
+ 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions. '
|
|
287
405
|
+ 'read_task_output: the end of a background shell task\'s output. send: prompt an '
|
|
288
|
-
+ 'existing session. create_session: start a new session with a first prompt.
|
|
406
|
+
+ 'existing session. create_session: start a new session with a first prompt. update_session: change '
|
|
407
|
+
+ 'another session\'s settings (model, effort, mode, auto-compact window, title, group, thinking, cwd). wait: keep waiting for '
|
|
289
408
|
+ 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
|
|
290
409
|
+ 'archive: archive a session. subscribe / unsubscribe / list_subscriptions / transfer_subscriptions: '
|
|
291
|
-
+ 'be woken by events of sessions on this relay or a paired one (relay may be left out: "this").'
|
|
410
|
+
+ 'be woken by events of sessions on this relay or a paired one (relay may be left out: "this"). '
|
|
411
|
+
+ 'schedule / list_schedules / unschedule: a prompt this relay queues at a set time, once or repeated (relay may be left out). '
|
|
412
|
+
+ 'ask / list_questions / withdraw: a question for the user that does not block you; the answer comes as a relay event (relay may be left out).',
|
|
292
413
|
},
|
|
293
414
|
relay: { type: 'string', description: 'Relay name (or its host) from list_relays, or "this" for the relay you run on. Required except for list_relays.' },
|
|
294
|
-
session: {
|
|
295
|
-
|
|
415
|
+
session: {
|
|
416
|
+
type: 'string',
|
|
417
|
+
description: 'Session (conversation) id from list_sessions or create_session. schedule: the session the prompt goes to '
|
|
418
|
+
+ '(default: your own conversation). list_schedules: another conversation whose schedules to list (default: yours). '
|
|
419
|
+
+ 'ask: the session to ask on behalf of (default: yours). list_questions: another session whose asks to list.',
|
|
420
|
+
},
|
|
421
|
+
text: { type: 'string', description: 'send / create_session: the prompt for the other agent. schedule: the prompt to queue at the time.' },
|
|
296
422
|
message_id: { type: 'string', description: 'wait: the message_id that send or create_session returned.' },
|
|
297
423
|
wait_seconds: {
|
|
298
424
|
type: 'integer',
|
|
@@ -326,11 +452,15 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
326
452
|
model: {
|
|
327
453
|
type: 'string',
|
|
328
454
|
description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
|
|
329
|
-
+ 'send: switch the remote session to this model (default: it keeps its own). '
|
|
455
|
+
+ 'send: switch the remote session to this model (default: it keeps its own). update_session: the session\'s model from now on. '
|
|
330
456
|
+ 'Must be an id relay_info lists for the provider (Claude also takes "opus", "sonnet", "haiku"): '
|
|
331
457
|
+ 'an unknown id is refused with the close matches, nothing created or sent.',
|
|
332
458
|
},
|
|
333
|
-
cwd: {
|
|
459
|
+
cwd: {
|
|
460
|
+
type: 'string',
|
|
461
|
+
description: 'create_session: working directory on the target relay (default: its default workspace). update_session: the '
|
|
462
|
+
+ 'session\'s working directory from now on; its CLI restarts there (refused while a turn or a background task runs). Not for claude-cloud.',
|
|
463
|
+
},
|
|
334
464
|
repo: {
|
|
335
465
|
type: 'string',
|
|
336
466
|
description: 'create_session with provider "claude-cloud" (required there, an error with any other provider): '
|
|
@@ -343,16 +473,65 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
343
473
|
mode: {
|
|
344
474
|
type: 'string',
|
|
345
475
|
enum: [...REMOTE_RELAY_MODES],
|
|
346
|
-
description: 'send / create_session: relay mode for the remote turn (default: this session\'s mode).'
|
|
476
|
+
description: 'send / create_session: relay mode for the remote turn (default: this session\'s mode). '
|
|
477
|
+
+ 'update_session: the session\'s mode from now on.',
|
|
347
478
|
},
|
|
348
479
|
effort: {
|
|
349
480
|
type: 'string',
|
|
350
481
|
description: 'send / create_session: reasoning effort for the remote turn, e.g. low, medium, high (relay_info lists '
|
|
351
482
|
+ 'what each model takes). create_session default: this session\'s effort when the remote model supports it, '
|
|
352
|
-
+ 'else the remote\'s default. send default: the remote session keeps its own.'
|
|
483
|
+
+ 'else the remote\'s default. send default: the remote session keeps its own. update_session: the session\'s '
|
|
484
|
+
+ 'effort from now on (one its model takes).',
|
|
485
|
+
},
|
|
486
|
+
title: {
|
|
487
|
+
type: 'string',
|
|
488
|
+
description: 'create_session: session title (default: from the prompt). update_session: the new title. '
|
|
489
|
+
+ 'schedule: a short name for the schedule.',
|
|
490
|
+
},
|
|
491
|
+
group: {
|
|
492
|
+
type: 'string',
|
|
493
|
+
description: 'create_session: an existing session group on the target relay to put the new session in: its id (grp_…) '
|
|
494
|
+
+ 'or its exact name, case-insensitive (relay_info lists them). Default: a session started on this relay joins '
|
|
495
|
+
+ 'your own group, one on a paired relay none. An unknown group is refused with the groups there, nothing created. '
|
|
496
|
+
+ `update_session: move the session into a group (id or name; a name the relay does not have yet makes a new group) or out of its group ("${REMOTE_RELAY_NO_GROUP}"). `
|
|
497
|
+
+ 'A group the owner made is never removed this way: it stays, empty if the session was its last member; one update_session made goes once empty. '
|
|
498
|
+
+ 'Renaming and dissolving groups is the owner\'s.',
|
|
499
|
+
},
|
|
500
|
+
auto_compact_window: {
|
|
501
|
+
type: 'integer',
|
|
502
|
+
minimum: 0,
|
|
503
|
+
description: 'update_session: how many tokens of context a Claude session gathers before it compacts '
|
|
504
|
+
+ `(${AUTO_COMPACT_WINDOW_STOPS.filter(Boolean).join(', ')}; other numbers go to the nearest), or 0 for the model default.`,
|
|
505
|
+
},
|
|
506
|
+
thinking_enabled: { type: 'boolean', description: 'update_session: whether a Claude session thinks (extended thinking) on its next turns.' },
|
|
507
|
+
thinking_display: {
|
|
508
|
+
type: 'string',
|
|
509
|
+
enum: [...THINKING_DISPLAY_MODES],
|
|
510
|
+
description: 'update_session: whether a Claude session\'s thinking text shows in its transcript (summarized) or not (omitted).',
|
|
511
|
+
},
|
|
512
|
+
question_id: { type: 'string', description: 'answer_question: id from pendingQuestions. withdraw: the id ask returned.' },
|
|
513
|
+
question: {
|
|
514
|
+
type: 'string',
|
|
515
|
+
maxLength: RELAY_ASK_LIMITS.questionMax,
|
|
516
|
+
description: `ask: the question for the user, at most ${RELAY_ASK_LIMITS.questionMax} characters (background goes in detail).`,
|
|
517
|
+
},
|
|
518
|
+
options: {
|
|
519
|
+
type: 'array',
|
|
520
|
+
items: { type: 'string' },
|
|
521
|
+
maxItems: RELAY_ASK_LIMITS.optionsMax,
|
|
522
|
+
description: `ask: up to ${RELAY_ASK_LIMITS.optionsMax} short answer labels shown as buttons (the user may also type an answer).`,
|
|
523
|
+
},
|
|
524
|
+
recommendation: { type: 'string', description: 'ask: the answer you recommend: one of the options, or free text.' },
|
|
525
|
+
detail: {
|
|
526
|
+
type: 'string',
|
|
527
|
+
maxLength: RELAY_ASK_LIMITS.detailMax,
|
|
528
|
+
description: `ask: background for the user, shown folded, at most ${RELAY_ASK_LIMITS.detailMax} characters (plain text).`,
|
|
529
|
+
},
|
|
530
|
+
urgency: {
|
|
531
|
+
type: 'string',
|
|
532
|
+
enum: [...RELAY_ASK_URGENCIES],
|
|
533
|
+
description: 'ask: normal (default), or now to send the user a push notification as well.',
|
|
353
534
|
},
|
|
354
|
-
title: { type: 'string', description: 'create_session: session title (default: from the prompt).' },
|
|
355
|
-
question_id: { type: 'string', description: 'answer_question: id from pendingQuestions.' },
|
|
356
535
|
answer: { type: 'string', description: 'answer_question: free-text answer.' },
|
|
357
536
|
choices: {
|
|
358
537
|
type: 'array',
|
|
@@ -376,7 +555,11 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
376
555
|
description: 'subscribe: which events (default: all but task_started, task_finished and relay_restarted, with subagent_finished: '
|
|
377
556
|
+ 'a shell command that ended well wakes you only when you name task_finished; relay_restarted wakes you when this relay runs again).',
|
|
378
557
|
},
|
|
379
|
-
once: {
|
|
558
|
+
once: {
|
|
559
|
+
type: 'boolean',
|
|
560
|
+
description: 'subscribe: end the subscription after it fired (with match: after the first match). '
|
|
561
|
+
+ 'schedule: fire once and end (default true with at alone, false with every_minutes).',
|
|
562
|
+
},
|
|
380
563
|
match: {
|
|
381
564
|
type: 'string',
|
|
382
565
|
description: 'subscribe: a regular expression (case-insensitive) a turn_end reply or a task summary must match; other events pass.',
|
|
@@ -393,9 +576,46 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
393
576
|
maximum: RELAY_EVENT_LIMITS.heartbeatMinutesMax,
|
|
394
577
|
description: 'subscribe: a heartbeat event every N minutes while a watched turn or task runs (default: none).',
|
|
395
578
|
},
|
|
579
|
+
digest_minutes: {
|
|
580
|
+
type: 'integer',
|
|
581
|
+
minimum: RELAY_EVENT_LIMITS.digestMinutesMin,
|
|
582
|
+
maximum: RELAY_EVENT_LIMITS.digestMinutesMax,
|
|
583
|
+
description: 'subscribe: gather this subscription\'s events into one message every N minutes '
|
|
584
|
+
+ '(questions and errors still come at once; default: none).',
|
|
585
|
+
},
|
|
586
|
+
quiet_hours: {
|
|
587
|
+
type: 'object',
|
|
588
|
+
properties: {
|
|
589
|
+
from: { type: 'string', description: 'Start, "HH:MM" local time.' },
|
|
590
|
+
to: { type: 'string', description: 'End, "HH:MM" local time (may be past midnight).' },
|
|
591
|
+
tz: { type: 'string', description: 'IANA time zone, e.g. "Europe/Berlin".' },
|
|
592
|
+
},
|
|
593
|
+
required: ['from', 'to', 'tz'],
|
|
594
|
+
description: 'subscribe: hold this subscription\'s events in this daily window and deliver them as one message '
|
|
595
|
+
+ 'when it ends (questions and errors still come at once; default: none).',
|
|
596
|
+
},
|
|
597
|
+
exclude: {
|
|
598
|
+
type: 'array',
|
|
599
|
+
items: { type: 'string' },
|
|
600
|
+
maxItems: RELAY_EVENT_LIMITS.excludeMax,
|
|
601
|
+
description: `subscribe with sessions ["all"]: session ids to leave out (at most ${RELAY_EVENT_LIMITS.excludeMax}).`,
|
|
602
|
+
},
|
|
396
603
|
subscription_id: { type: 'string', description: 'unsubscribe: the id subscribe returned, or "all".' },
|
|
397
604
|
watcher: { type: 'string', description: 'list_subscriptions: another conversation\'s id, to see its subscriptions (default: this one).' },
|
|
398
605
|
from: { type: 'string', description: 'transfer_subscriptions: the id of the conversation whose subscriptions move here.' },
|
|
606
|
+
at: {
|
|
607
|
+
type: 'string',
|
|
608
|
+
description: 'schedule: when to queue the prompt, an ISO time with a zone (e.g. 2026-11-02T06:30:00Z); '
|
|
609
|
+
+ 'with every_minutes the first time.',
|
|
610
|
+
},
|
|
611
|
+
every_minutes: {
|
|
612
|
+
type: 'integer',
|
|
613
|
+
minimum: RELAY_SCHEDULE_LIMITS.everyMinutesMin,
|
|
614
|
+
maximum: RELAY_SCHEDULE_LIMITS.everyMinutesMax,
|
|
615
|
+
description: `schedule: repeat every N minutes (${RELAY_SCHEDULE_LIMITS.everyMinutesMin} to ${RELAY_SCHEDULE_LIMITS.everyMinutesMax}); `
|
|
616
|
+
+ 'the first time is at, else N minutes from now.',
|
|
617
|
+
},
|
|
618
|
+
schedule_id: { type: 'string', description: 'unschedule: the id schedule or list_schedules returned.' },
|
|
399
619
|
task_id: { type: 'string', description: 'read_task_output: the background task\'s id (taskId in read_session\'s tasks).' },
|
|
400
620
|
lines: {
|
|
401
621
|
type: 'integer',
|
|
@@ -422,7 +642,67 @@ function invalid(error) {
|
|
|
422
642
|
return { ok: false, code: REMOTE_RELAY_ERROR_CODES.invalidInput, error };
|
|
423
643
|
}
|
|
424
644
|
|
|
425
|
-
const SESSION_ACTIONS = new Set(['read_session', 'read_task_output', 'wait', 'send', 'stop', 'archive']);
|
|
645
|
+
const SESSION_ACTIONS = new Set(['read_session', 'read_task_output', 'wait', 'send', 'update_session', 'stop', 'archive']);
|
|
646
|
+
|
|
647
|
+
// Actions that refuse an argument they do not take (nothing is changed),
|
|
648
|
+
// where the others name it in `ignored`.
|
|
649
|
+
const STRICT_ARGUMENT_ACTIONS = new Set(['create_session', 'update_session']);
|
|
650
|
+
|
|
651
|
+
// The arguments each action reads besides `action` and `relay`, including what
|
|
652
|
+
// the first check hands the second (subscribe's `scope: "all"`). Anything else
|
|
653
|
+
// is refused by create_session and update_session and listed as `ignored` by
|
|
654
|
+
// the others.
|
|
655
|
+
export const REMOTE_RELAY_ACTION_ARGUMENTS = Object.freeze({
|
|
656
|
+
list_relays: Object.freeze([]),
|
|
657
|
+
relay_info: Object.freeze([]),
|
|
658
|
+
usage: Object.freeze([]),
|
|
659
|
+
list_sessions: Object.freeze(['scope', 'limit', 'query', 'cursor']),
|
|
660
|
+
read_session: Object.freeze(['session', 'last', 'before', 'include_activity', 'max_chars']),
|
|
661
|
+
read_task_output: Object.freeze(['session', 'task_id', 'lines']),
|
|
662
|
+
wait: Object.freeze(['session', 'message_id', 'wait_seconds']),
|
|
663
|
+
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy']),
|
|
664
|
+
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds']),
|
|
665
|
+
update_session: Object.freeze(['session', ...REMOTE_RELAY_SESSION_SETTINGS]),
|
|
666
|
+
answer_question: Object.freeze(['question_id', 'answer', 'choices']),
|
|
667
|
+
stop: Object.freeze(['session']),
|
|
668
|
+
archive: Object.freeze(['session']),
|
|
669
|
+
subscribe: Object.freeze([
|
|
670
|
+
'sessions', 'session', 'scope', 'events', 'once', 'match', 'stalled_minutes', 'heartbeat_minutes', 'digest_minutes', 'quiet_hours', 'exclude',
|
|
671
|
+
]),
|
|
672
|
+
unsubscribe: Object.freeze(['subscription_id']),
|
|
673
|
+
list_subscriptions: Object.freeze(['watcher']),
|
|
674
|
+
transfer_subscriptions: Object.freeze(['from']),
|
|
675
|
+
schedule: Object.freeze(['session', 'text', 'at', 'every_minutes', 'once', 'title']),
|
|
676
|
+
list_schedules: Object.freeze(['session']),
|
|
677
|
+
unschedule: Object.freeze(['schedule_id']),
|
|
678
|
+
ask: Object.freeze(['session', 'question', 'options', 'recommendation', 'detail', 'urgency']),
|
|
679
|
+
list_questions: Object.freeze(['session']),
|
|
680
|
+
withdraw: Object.freeze(['question_id']),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const IGNORED_ARGUMENTS_MAX = 20;
|
|
684
|
+
|
|
685
|
+
// Some providers send every property of the schema, empty ones as null, '' or
|
|
686
|
+
// false: such a value is no argument.
|
|
687
|
+
function isEmptyArgument(value) {
|
|
688
|
+
if (value === undefined || value === null || value === false) return true;
|
|
689
|
+
if (typeof value === 'string') return value.trim() === '';
|
|
690
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
691
|
+
// An object property (quiet_hours) sent with every field empty.
|
|
692
|
+
if (typeof value === 'object') return Object.values(value).every(isEmptyArgument);
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The keys of `input` the action does not take (empty values left out), sorted and capped. */
|
|
697
|
+
function argumentsNotTaken(action, input) {
|
|
698
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return [];
|
|
699
|
+
const taken = new Set(['action', 'relay', ...(REMOTE_RELAY_ACTION_ARGUMENTS[action] || [])]);
|
|
700
|
+
return Object.keys(input)
|
|
701
|
+
.filter((key) => !taken.has(key) && !isEmptyArgument(input[key]))
|
|
702
|
+
.sort()
|
|
703
|
+
.slice(0, IGNORED_ARGUMENTS_MAX)
|
|
704
|
+
.map((key) => key.slice(0, 60));
|
|
705
|
+
}
|
|
426
706
|
|
|
427
707
|
/**
|
|
428
708
|
* The longest wait a relay may be set to allow, from whatever was stored or
|
|
@@ -462,8 +742,26 @@ export function normalizeRemoteRelayRepo(value) {
|
|
|
462
742
|
* `maxWaitSeconds` is the relay's own setting; without it (an adapter, which
|
|
463
743
|
* does not know the setting) wait_seconds is clamped to the ceiling only.
|
|
464
744
|
* `waitLimitedFrom` is set on the result when the relay's setting cut the wait.
|
|
745
|
+
*
|
|
746
|
+
* An argument the action does not take is never dropped in silence:
|
|
747
|
+
* create_session and update_session refuse it (nothing is created or
|
|
748
|
+
* changed), every other action lists it in `ignored` on the result, which the
|
|
749
|
+
* caller passes on to the agent.
|
|
465
750
|
*/
|
|
466
|
-
export function validateRemoteRelayToolInput(input = {},
|
|
751
|
+
export function validateRemoteRelayToolInput(input = {}, options = {}) {
|
|
752
|
+
const action = toText(input?.action).toLowerCase();
|
|
753
|
+
const notTaken = REMOTE_RELAY_ACTIONS.includes(action) ? argumentsNotTaken(action, input) : [];
|
|
754
|
+
if (STRICT_ARGUMENT_ACTIONS.has(action) && notTaken.length) {
|
|
755
|
+
const ownerOnly = action === 'update_session' && notTaken.some((key) => /^may_?create_?sessions$/i.test(key))
|
|
756
|
+
? ' Whether a session may start sessions is the owner\'s setting alone.'
|
|
757
|
+
: '';
|
|
758
|
+
return invalid(`${action} does not take: ${notTaken.join(', ')}. Its arguments: relay, ${REMOTE_RELAY_ACTION_ARGUMENTS[action].join(', ')}.${ownerOnly} Nothing was ${action === 'create_session' ? 'created' : 'changed'}.`);
|
|
759
|
+
}
|
|
760
|
+
const result = validateToolInput(input, options);
|
|
761
|
+
return notTaken.length ? { ...result, ignored: notTaken } : result;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function validateToolInput(input, { maxWaitSeconds, now } = {}) {
|
|
467
765
|
const action = toText(input?.action).toLowerCase();
|
|
468
766
|
if (!REMOTE_RELAY_ACTIONS.includes(action)) {
|
|
469
767
|
return invalid(`action must be one of: ${REMOTE_RELAY_ACTIONS.join(', ')}`);
|
|
@@ -471,10 +769,15 @@ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}
|
|
|
471
769
|
if (action === 'list_relays') return { ok: true, action, args: {} };
|
|
472
770
|
|
|
473
771
|
const args = {};
|
|
474
|
-
const
|
|
772
|
+
const relayOptional = REMOTE_RELAY_EVENT_ACTIONS.includes(action)
|
|
773
|
+
|| REMOTE_RELAY_SCHEDULE_ACTIONS.includes(action)
|
|
774
|
+
|| REMOTE_RELAY_ASK_ACTIONS.includes(action);
|
|
775
|
+
const relay = toText(input?.relay) || (relayOptional ? REMOTE_RELAY_LOCAL_ALIAS : '');
|
|
475
776
|
if (!relay) return invalid(`${action} needs relay (a name from list_relays)`);
|
|
476
777
|
args.relay = relay;
|
|
477
778
|
if (REMOTE_RELAY_EVENT_ACTIONS.includes(action)) return validateEventAction(action, input, args);
|
|
779
|
+
if (REMOTE_RELAY_SCHEDULE_ACTIONS.includes(action)) return validateScheduleAction(action, input, args, { now });
|
|
780
|
+
if (REMOTE_RELAY_ASK_ACTIONS.includes(action)) return validateAskAction(action, input, args);
|
|
478
781
|
|
|
479
782
|
if (SESSION_ACTIONS.has(action)) {
|
|
480
783
|
const session = toText(input?.session);
|
|
@@ -569,6 +872,11 @@ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}
|
|
|
569
872
|
if (!providerCheck.ok) return providerCheck;
|
|
570
873
|
optionalText('cwd', 1000);
|
|
571
874
|
optionalText('title', 200);
|
|
875
|
+
// An empty group is no group: the default grouping applies.
|
|
876
|
+
if (input?.group !== undefined && input?.group !== null && typeof input.group !== 'string') {
|
|
877
|
+
return invalid('group must be text: a group id (grp_…) or a group name from relay_info');
|
|
878
|
+
}
|
|
879
|
+
optionalText('group', 200);
|
|
572
880
|
// A Claude Cloud session clones a repository instead of opening a
|
|
573
881
|
// folder: repo and branch belong to that provider alone. A call that
|
|
574
882
|
// names no provider gets this session's, so "repo is missing" for a
|
|
@@ -593,6 +901,10 @@ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}
|
|
|
593
901
|
args.branch = branchText;
|
|
594
902
|
}
|
|
595
903
|
}
|
|
904
|
+
} else if (action === 'update_session') {
|
|
905
|
+
const settings = validateRemoteRelaySessionSettings(input);
|
|
906
|
+
if (!settings.ok) return settings;
|
|
907
|
+
Object.assign(args, settings.values);
|
|
596
908
|
} else if (action === 'answer_question') {
|
|
597
909
|
const questionId = toText(input?.question_id);
|
|
598
910
|
if (!questionId) return invalid('answer_question needs question_id (from pendingQuestions)');
|
|
@@ -609,6 +921,84 @@ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}
|
|
|
609
921
|
return { ok: true, action, args, ...(waits ? waitLimited : {}) };
|
|
610
922
|
}
|
|
611
923
|
|
|
924
|
+
function readBoolean(value) {
|
|
925
|
+
if (value === true || value === false) return value;
|
|
926
|
+
const text = toText(value).toLowerCase();
|
|
927
|
+
if (['true', 'on', '1'].includes(text)) return true;
|
|
928
|
+
if (['false', 'off', '0'].includes(text)) return false;
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* update_session's settings, each checked for its shape: `{ ok, values }`
|
|
934
|
+
* with the ones the call names, or `{ ok:false }`. A setting left empty (null,
|
|
935
|
+
* '') is not named, except `group: null`, which takes the session out of its
|
|
936
|
+
* group like "none". The values come out in a form this check takes again
|
|
937
|
+
* (the worker's tool and the relay both run it). Whether the model and the
|
|
938
|
+
* effort exist there, and what a window snaps to, is the relay's to say.
|
|
939
|
+
*/
|
|
940
|
+
export function validateRemoteRelaySessionSettings(input) {
|
|
941
|
+
const values = {};
|
|
942
|
+
const text = (key, max) => {
|
|
943
|
+
const value = input?.[key];
|
|
944
|
+
if (value === undefined || value === null) return { ok: true };
|
|
945
|
+
if (typeof value !== 'string') return invalid(`${key} must be text`);
|
|
946
|
+
if (value.trim()) values[key] = value.trim().slice(0, max);
|
|
947
|
+
return { ok: true };
|
|
948
|
+
};
|
|
949
|
+
for (const [key, max] of [['model', 200], ['title', 200], ['cwd', 1000]]) {
|
|
950
|
+
const check = text(key, max);
|
|
951
|
+
if (!check.ok) return check;
|
|
952
|
+
}
|
|
953
|
+
const mode = toText(input?.mode).toLowerCase();
|
|
954
|
+
if (mode) {
|
|
955
|
+
if (!REMOTE_RELAY_MODES.includes(mode)) return invalid(`mode must be one of: ${REMOTE_RELAY_MODES.join(', ')}`);
|
|
956
|
+
values.mode = mode;
|
|
957
|
+
}
|
|
958
|
+
const effort = toText(input?.effort).toLowerCase();
|
|
959
|
+
if (effort) {
|
|
960
|
+
if (!REMOTE_RELAY_EFFORT_PATTERN.test(effort)) {
|
|
961
|
+
return invalid('effort must be a short word such as low, medium or high (letters, digits and dashes, at most 32 characters)');
|
|
962
|
+
}
|
|
963
|
+
values.effort = effort;
|
|
964
|
+
}
|
|
965
|
+
const window = input?.auto_compact_window;
|
|
966
|
+
if (window !== undefined && window !== null && toText(window) !== '') {
|
|
967
|
+
const word = toText(window).toLowerCase();
|
|
968
|
+
const number = Number(window);
|
|
969
|
+
// A boolean is no window (false is not 0).
|
|
970
|
+
if (typeof window === 'boolean') return invalid('auto_compact_window must be a number of tokens (such as 200000), or 0 for the model default');
|
|
971
|
+
if (word === 'auto' || number === 0) values.auto_compact_window = 'auto';
|
|
972
|
+
else if (Number.isFinite(number) && number > 0) values.auto_compact_window = Math.trunc(number);
|
|
973
|
+
else return invalid('auto_compact_window must be a number of tokens (such as 200000), or 0 for the model default');
|
|
974
|
+
}
|
|
975
|
+
if (Object.prototype.hasOwnProperty.call(input || {}, 'group')) {
|
|
976
|
+
const group = input.group;
|
|
977
|
+
if (group === null) {
|
|
978
|
+
values.group = null;
|
|
979
|
+
} else if (typeof group !== 'string') {
|
|
980
|
+
return invalid(`group must be text: a group id (grp_…) or name, or "${REMOTE_RELAY_NO_GROUP}" to take the session out of its group`);
|
|
981
|
+
} else if (group.trim()) {
|
|
982
|
+
values.group = group.trim().toLowerCase() === REMOTE_RELAY_NO_GROUP ? null : group.trim().slice(0, 200);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const thinking = input?.thinking_enabled;
|
|
986
|
+
if (thinking !== undefined && thinking !== null && toText(thinking) !== '') {
|
|
987
|
+
const enabled = readBoolean(thinking);
|
|
988
|
+
if (enabled === null) return invalid('thinking_enabled must be true or false');
|
|
989
|
+
values.thinking_enabled = enabled;
|
|
990
|
+
}
|
|
991
|
+
const display = toText(input?.thinking_display).toLowerCase();
|
|
992
|
+
if (display) {
|
|
993
|
+
if (!THINKING_DISPLAY_MODES.includes(display)) return invalid(`thinking_display must be one of: ${THINKING_DISPLAY_MODES.join(', ')}`);
|
|
994
|
+
values.thinking_display = display;
|
|
995
|
+
}
|
|
996
|
+
if (!Object.keys(values).length) {
|
|
997
|
+
return invalid(`update_session needs at least one setting to change: ${REMOTE_RELAY_SESSION_SETTINGS.join(', ')}`);
|
|
998
|
+
}
|
|
999
|
+
return { ok: true, values };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
612
1002
|
function optionalMinutes(input, key, { min, max }) {
|
|
613
1003
|
const raw = input?.[key];
|
|
614
1004
|
if (raw === undefined || raw === null || toText(raw) === '') return { ok: true, value: null };
|
|
@@ -665,6 +1055,21 @@ function validateEventAction(action, input, args) {
|
|
|
665
1055
|
const heartbeat = optionalMinutes(input, 'heartbeat_minutes', { min: RELAY_EVENT_LIMITS.heartbeatMinutesMin, max: RELAY_EVENT_LIMITS.heartbeatMinutesMax });
|
|
666
1056
|
if (!heartbeat.ok) return heartbeat;
|
|
667
1057
|
if (heartbeat.value !== null) args.heartbeat_minutes = heartbeat.value;
|
|
1058
|
+
const digest = optionalMinutes(input, 'digest_minutes', { min: RELAY_EVENT_LIMITS.digestMinutesMin, max: RELAY_EVENT_LIMITS.digestMinutesMax });
|
|
1059
|
+
if (!digest.ok) return digest;
|
|
1060
|
+
if (digest.value !== null) args.digest_minutes = digest.value;
|
|
1061
|
+
const quiet = normalizeRelayEventQuietHours(input?.quiet_hours);
|
|
1062
|
+
if (!quiet.ok) return invalid(quiet.error);
|
|
1063
|
+
if (quiet.value) args.quiet_hours = quiet.value;
|
|
1064
|
+
let exclude = input?.exclude;
|
|
1065
|
+
if (typeof exclude === 'string') exclude = exclude.split(',');
|
|
1066
|
+
if (exclude !== undefined && exclude !== null && !Array.isArray(exclude)) return invalid('exclude must be a list of session ids');
|
|
1067
|
+
const excluded = [...new Set((exclude || []).map(toText).filter(Boolean))];
|
|
1068
|
+
if (excluded.length) {
|
|
1069
|
+
if (!all) return invalid('exclude goes with sessions ["all"] only; a list of sessions names what it watches');
|
|
1070
|
+
if (excluded.length > RELAY_EVENT_LIMITS.excludeMax) return invalid(`exclude takes at most ${RELAY_EVENT_LIMITS.excludeMax} sessions`);
|
|
1071
|
+
args.exclude = excluded.map((id) => id.slice(0, 200));
|
|
1072
|
+
}
|
|
668
1073
|
} else if (action === 'unsubscribe') {
|
|
669
1074
|
const id = toText(input?.subscription_id);
|
|
670
1075
|
if (!id) return invalid('unsubscribe needs subscription_id (from subscribe or list_subscriptions), or "all"');
|
|
@@ -680,6 +1085,73 @@ function validateEventAction(action, input, args) {
|
|
|
680
1085
|
return { ok: true, action, args };
|
|
681
1086
|
}
|
|
682
1087
|
|
|
1088
|
+
function optionalBoolean(value) {
|
|
1089
|
+
if (value === true || value === false) return value;
|
|
1090
|
+
const text = toText(value).toLowerCase();
|
|
1091
|
+
if (text === 'true') return true;
|
|
1092
|
+
if (text === 'false') return false;
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function validateScheduleAction(action, input, args, { now = Date.now() } = {}) {
|
|
1097
|
+
const session = toText(input?.session);
|
|
1098
|
+
if (action === 'schedule') {
|
|
1099
|
+
if (session) args.session = session.slice(0, 200);
|
|
1100
|
+
const text = toText(input?.text);
|
|
1101
|
+
if (!text) return invalid('schedule needs text (the prompt to queue)');
|
|
1102
|
+
if (text.length > RELAY_SCHEDULE_LIMITS.textMax) return invalid(`text is longer than ${RELAY_SCHEDULE_LIMITS.textMax} characters`);
|
|
1103
|
+
args.text = text;
|
|
1104
|
+
const at = normalizeRelayScheduleAt(input?.at, { now });
|
|
1105
|
+
if (!at.ok) return invalid(at.error);
|
|
1106
|
+
const rawEvery = input?.every_minutes;
|
|
1107
|
+
let every = null;
|
|
1108
|
+
if (rawEvery !== undefined && rawEvery !== null && toText(rawEvery) !== '') {
|
|
1109
|
+
const number = Number(rawEvery);
|
|
1110
|
+
const { everyMinutesMin: min, everyMinutesMax: max } = RELAY_SCHEDULE_LIMITS;
|
|
1111
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
1112
|
+
return invalid(`every_minutes must be a whole number of minutes from ${min} to ${max}`);
|
|
1113
|
+
}
|
|
1114
|
+
every = number;
|
|
1115
|
+
}
|
|
1116
|
+
if (!at.value && every === null) return invalid('schedule needs at (an ISO time) or every_minutes');
|
|
1117
|
+
const once = optionalBoolean(input?.once);
|
|
1118
|
+
if (once === false && every === null) return invalid('a schedule that repeats needs every_minutes');
|
|
1119
|
+
if (at.value) args.at = at.value;
|
|
1120
|
+
if (every !== null) args.every_minutes = every;
|
|
1121
|
+
args.once = once ?? every === null;
|
|
1122
|
+
const title = normalizeRelayScheduleTitle(input?.title);
|
|
1123
|
+
if (title) args.title = title;
|
|
1124
|
+
} else if (action === 'list_schedules') {
|
|
1125
|
+
if (session) args.session = session.slice(0, 200);
|
|
1126
|
+
} else if (action === 'unschedule') {
|
|
1127
|
+
const id = toText(input?.schedule_id);
|
|
1128
|
+
if (!id) return invalid('unschedule needs schedule_id (from schedule or list_schedules)');
|
|
1129
|
+
args.schedule_id = id.slice(0, 100);
|
|
1130
|
+
}
|
|
1131
|
+
return { ok: true, action, args };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function validateAskAction(action, input, args) {
|
|
1135
|
+
const session = toText(input?.session);
|
|
1136
|
+
if (action === 'ask') {
|
|
1137
|
+
if (session) args.session = session.slice(0, 200);
|
|
1138
|
+
const checked = normalizeRelayAskInput(input);
|
|
1139
|
+
if (!checked.ok) return invalid(checked.error);
|
|
1140
|
+
args.question = checked.value.question;
|
|
1141
|
+
if (checked.value.options.length) args.options = checked.value.options;
|
|
1142
|
+
if (checked.value.recommendation) args.recommendation = checked.value.recommendation;
|
|
1143
|
+
if (checked.value.detail) args.detail = checked.value.detail;
|
|
1144
|
+
args.urgency = checked.value.urgency;
|
|
1145
|
+
} else if (action === 'list_questions') {
|
|
1146
|
+
if (session) args.session = session.slice(0, 200);
|
|
1147
|
+
} else if (action === 'withdraw') {
|
|
1148
|
+
const id = toText(input?.question_id);
|
|
1149
|
+
if (!id) return invalid('withdraw needs question_id (the id ask returned)');
|
|
1150
|
+
args.question_id = id.slice(0, 100);
|
|
1151
|
+
}
|
|
1152
|
+
return { ok: true, action, args };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
683
1155
|
const PERMISSION_RANK = { read: 1, prompt: 2, full: 3 };
|
|
684
1156
|
|
|
685
1157
|
export function normalizeRemoteRelayPermission(value) {
|
|
@@ -687,10 +1159,10 @@ export function normalizeRemoteRelayPermission(value) {
|
|
|
687
1159
|
return REMOTE_RELAY_PERMISSIONS.includes(permission) ? permission : REMOTE_RELAY_DEFAULT_PERMISSION;
|
|
688
1160
|
}
|
|
689
1161
|
|
|
690
|
-
/** True when a remote with `permission` allows `action
|
|
691
|
-
export function remoteRelayPermissionAllows(permission, action) {
|
|
1162
|
+
/** True when a remote with `permission` allows `action` (`ownSession`: see remoteRelayActionPermission). */
|
|
1163
|
+
export function remoteRelayPermissionAllows(permission, action, { ownSession = false } = {}) {
|
|
692
1164
|
if (!Object.prototype.hasOwnProperty.call(REMOTE_RELAY_ACTION_PERMISSION, action)) return false;
|
|
693
|
-
const needed =
|
|
1165
|
+
const needed = remoteRelayActionPermission(action, { ownSession });
|
|
694
1166
|
if (needed === null) return true;
|
|
695
1167
|
return (PERMISSION_RANK[normalizeRemoteRelayPermission(permission)] || 0) >= PERMISSION_RANK[needed];
|
|
696
1168
|
}
|
|
@@ -703,6 +1175,47 @@ export function isRemoteRelayPromptAction(action) {
|
|
|
703
1175
|
return REMOTE_RELAY_PROMPT_ACTIONS.includes(action);
|
|
704
1176
|
}
|
|
705
1177
|
|
|
1178
|
+
// ─── Session groups ──────────────────────────────────────────────────────────
|
|
1179
|
+
|
|
1180
|
+
// How many groups relay_info lists, and how many an unknown-group refusal names.
|
|
1181
|
+
export const REMOTE_RELAY_GROUP_LIMITS = Object.freeze({ listMax: 50, refusalMax: 20 });
|
|
1182
|
+
|
|
1183
|
+
function groupNameKey(value) {
|
|
1184
|
+
return toText(value).replace(/\s+/g, ' ').toLowerCase();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* The group a create_session `group` names among `groups` (`[{ id, name }]`,
|
|
1189
|
+
* `name` being what the owner's sidebar shows): the id itself, else the one
|
|
1190
|
+
* group with that name (case-insensitive). The relay that creates the session
|
|
1191
|
+
* and the relay that asks both decide with this. Returns
|
|
1192
|
+
* `{ status: 'found', group }`, `{ status: 'unknown' }` or
|
|
1193
|
+
* `{ status: 'ambiguous', matches }`.
|
|
1194
|
+
*/
|
|
1195
|
+
export function resolveSessionGroupReference(groups, reference) {
|
|
1196
|
+
const list = (Array.isArray(groups) ? groups : []).filter((group) => toText(group?.id));
|
|
1197
|
+
const wanted = toText(reference);
|
|
1198
|
+
if (!wanted) return { status: 'unknown' };
|
|
1199
|
+
const byId = list.find((group) => toText(group.id) === wanted);
|
|
1200
|
+
if (byId) return { status: 'found', group: byId };
|
|
1201
|
+
const key = groupNameKey(wanted);
|
|
1202
|
+
const matches = list.filter((group) => groupNameKey(group.name) === key);
|
|
1203
|
+
if (matches.length === 1) return { status: 'found', group: matches[0] };
|
|
1204
|
+
return matches.length ? { status: 'ambiguous', matches } : { status: 'unknown' };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* `Alpha (grp_…), Beta (grp_…) and 3 more`, at most `max` named. `total` is
|
|
1209
|
+
* how many there are when `groups` is already a capped list.
|
|
1210
|
+
*/
|
|
1211
|
+
export function formatSessionGroupList(groups, max = REMOTE_RELAY_GROUP_LIMITS.refusalMax, total = null) {
|
|
1212
|
+
const list = Array.isArray(groups) ? groups : [];
|
|
1213
|
+
const named = list.slice(0, max).map((group) => `${toText(group?.name).replace(/[\r\n]+/g, ' ') || 'Group'} (${toText(group?.id)})`);
|
|
1214
|
+
const count = Number.isInteger(total) && total > list.length ? total : list.length;
|
|
1215
|
+
const more = count - named.length;
|
|
1216
|
+
return `${named.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
706
1219
|
// ─── Approval cards ──────────────────────────────────────────────────────────
|
|
707
1220
|
|
|
708
1221
|
// The Allow / Deny card a relay puts on its own turn before a write action on
|
|
@@ -897,6 +1410,21 @@ export function summarizeRemoteRelayCall(input = {}) {
|
|
|
897
1410
|
: (sessions.length ? `: ${sessions.length} session${sessions.length === 1 ? '' : 's'}` : text);
|
|
898
1411
|
} else if (action === 'transfer_subscriptions' && toText(input?.from)) {
|
|
899
1412
|
text = `: from session ${toText(input.from).slice(0, 8)}`;
|
|
1413
|
+
} else if (action === 'update_session') {
|
|
1414
|
+
const named = REMOTE_RELAY_SESSION_SETTINGS.filter((key) => (key === 'group' && input?.group === null)
|
|
1415
|
+
|| (input?.[key] !== undefined && input?.[key] !== null && toText(input[key]) !== ''));
|
|
1416
|
+
text = named.length ? `: ${named.join(', ')}` : '';
|
|
1417
|
+
} else if (action === 'schedule') {
|
|
1418
|
+
const every = Number(input?.every_minutes);
|
|
1419
|
+
const when = [toText(input?.at) ? `at ${toText(input.at)}` : '', every > 0 ? `every ${every} min` : ''].filter(Boolean).join(', ');
|
|
1420
|
+
const title = toText(input?.title) ? `“${clip(input.title, 40)}”` : '';
|
|
1421
|
+
text = when || title ? `: ${[when, title].filter(Boolean).join(' ')}` : text;
|
|
1422
|
+
} else if (action === 'unschedule' && toText(input?.schedule_id)) {
|
|
1423
|
+
text = `: ${toText(input.schedule_id).slice(0, 40)}`;
|
|
1424
|
+
} else if (action === 'ask' && toText(input?.question)) {
|
|
1425
|
+
text = `: “${clip(input.question, 60)}”${toText(input?.urgency).toLowerCase() === 'now' ? ' (now)' : ''}`;
|
|
1426
|
+
} else if (action === 'withdraw' && toText(input?.question_id)) {
|
|
1427
|
+
text = `: ${toText(input.question_id).slice(0, 40)}`;
|
|
900
1428
|
}
|
|
901
1429
|
return target ? `${action} → ${target}${text}` : `${action}${text}`;
|
|
902
1430
|
}
|