@oar-sh/oar 0.9.13 → 0.9.15
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/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +396 -0
- package/README.md +12 -12
- package/bin/oar.js +677 -103
- package/package.json +7 -5
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +49 -16
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +18 -5
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +415 -45
- package/server/services/windows-boot-autostart-service.mjs +962 -185
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2187
- package/server/uploads/image_input.md +0 -540
package/server/relay-tools.md
CHANGED
|
@@ -2,51 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.
|
|
4
4
|
|
|
5
|
-
When using ask_user, ALWAYS include a `choices` array with 2-
|
|
5
|
+
When using ask_user, ALWAYS include a `choices` array with 2-8 answer options so the web relay can render clickable buttons. The relay keeps the first 8 choices and drops the rest. Example:
|
|
6
6
|
```json
|
|
7
7
|
{
|
|
8
|
-
"question": "
|
|
9
|
-
"choices": ["
|
|
8
|
+
"question": "The login test fails because the session cookie expires too early. How should I fix it?",
|
|
9
|
+
"choices": ["Fix the cookie lifetime in the code", "Update the test to match the code", "Leave it and only report it"]
|
|
10
10
|
}
|
|
11
11
|
```
|
|
12
|
-
At runtime, inject the question and choices from a random item in your quiz pool.
|
|
13
12
|
Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
|
|
14
13
|
|
|
15
14
|
When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
|
|
16
15
|
|
|
17
16
|
In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
|
|
18
17
|
|
|
19
|
-
For relay
|
|
18
|
+
For a relay restart or shutdown, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
|
|
20
19
|
|
|
21
|
-
Note:
|
|
20
|
+
Note: the request is queued. The relay only shuts down or restarts once no turn is running on it, your own included (after a maximum wait it asks the user), and it holds new messages until then, so end your turn instead of waiting for the restart. The answer names the sessions it waits for (`waitingFor`), the messages it holds (`held`) and the background tasks (`backgroundTasks`, each with whether it `survives`): tell the user.
|
|
22
21
|
|
|
23
22
|
Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.
|
|
24
23
|
|
|
25
24
|
Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
|
|
26
25
|
|
|
26
|
+
To go on with your work once the relay is back, subscribe first with `remote_relay` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart: your own timers and scheduled jobs may not survive it. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
|
|
27
|
+
|
|
27
28
|
## Preview servers
|
|
28
29
|
|
|
29
|
-
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close
|
|
30
|
+
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close this conversation's previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
|
|
30
31
|
|
|
31
|
-
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews
|
|
32
|
+
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews?conversationId=<conv>` lists this conversation's previews, `DELETE /api/previews/:token?conversationId=<conv>` closes one of them without touching the dev server behind it. Always pass the conversation: you may list and close only this conversation's previews, another conversation's is refused (403), and the relay-wide list is the user's (Settings → Previews). A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
32
33
|
|
|
33
34
|
## Remote relays
|
|
34
35
|
|
|
35
36
|
If you have a `remote_relay` tool, it works with sessions on OAR relays: the other relays this relay is paired with and, when its owner allows it, this relay itself. It lists and reads sessions, prompts a session, starts a new one and waits for its reply. Start with `{"action":"list_relays"}`.
|
|
36
37
|
|
|
37
|
-
A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it.
|
|
38
|
+
A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name) or an agent on that relay writes to this conversation; a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it, or to answer an agent on it that wrote to this conversation.
|
|
38
39
|
|
|
39
40
|
This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
|
|
40
41
|
|
|
41
42
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
42
43
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
|
|
43
44
|
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
|
|
44
|
-
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches (
|
|
45
|
+
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
|
|
45
46
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
46
47
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
47
48
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
|
48
49
|
- `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
|
|
49
50
|
|
|
50
|
-
On this relay and on a paired one, `subscribe` wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command.
|
|
51
|
+
On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
|
|
52
|
+
|
|
53
|
+
The first `subscribe` of a conversation on a relay, and any to "all", shows the user an approval card. `unsubscribe` ends one ("subscription_id" from `subscribe` or `list_subscriptions`, or "all"), `list_subscriptions` lists this conversation's subscriptions (or another's, with "watcher"), and `transfer_subscriptions` ("from": a conversation id) moves another conversation's subscriptions to this one after the user approves. `usage` shows what each provider plan of a relay has left and whether a Claude usage limit has paused turns there: check it before you start work that costs. `stop` stops a session's running turn and `archive` archives a session. The `remote_relay` tool's own description is the full reference for every action and field.
|
|
51
54
|
|
|
52
55
|
Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|
|
@@ -18,6 +18,25 @@ export function createMessageRepository(db) {
|
|
|
18
18
|
const notHeldForUsageLimit = (alias = '') => (queueHasUsageLimitPause
|
|
19
19
|
? `AND NOT (${alias}usage_limit_pause IS NOT NULL AND ${alias}status = 'pending' AND ${alias}next_attempt_at IS NOT NULL AND ${alias}next_attempt_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`
|
|
20
20
|
: '');
|
|
21
|
+
// When a processing row is due for the stale sweep; one rule for the
|
|
22
|
+
// listings and the requeue, so the UPDATE can never take a row the
|
|
23
|
+
// listing did not. Inactivity is measured from the last heartbeat, the
|
|
24
|
+
// ceiling (@ceilingBefore, NULL = no limit) from the later of the turn's
|
|
25
|
+
// start and its last answered question, and a turn waiting on an
|
|
26
|
+
// unanswered question is never due. The comments at the statements say why.
|
|
27
|
+
const staleProcessingSql = `(
|
|
28
|
+
(
|
|
29
|
+
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
30
|
+
OR (
|
|
31
|
+
@ceilingBefore IS NOT NULL
|
|
32
|
+
AND MAX(
|
|
33
|
+
COALESCE(processing_at, timestamp),
|
|
34
|
+
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
35
|
+
) < @ceilingBefore
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
39
|
+
)`;
|
|
21
40
|
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
22
41
|
const messagesHaveSourceId = messageColumns.has('source_message_id');
|
|
23
42
|
const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
|
|
@@ -369,18 +388,13 @@ export function createMessageRepository(db) {
|
|
|
369
388
|
FROM queue
|
|
370
389
|
WHERE status = 'processing'
|
|
371
390
|
AND COALESCE(kind, '') != 'continuation'
|
|
372
|
-
AND
|
|
373
|
-
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
374
|
-
OR (
|
|
375
|
-
@ceilingBefore IS NOT NULL
|
|
376
|
-
AND MAX(
|
|
377
|
-
COALESCE(processing_at, timestamp),
|
|
378
|
-
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
379
|
-
) < @ceilingBefore
|
|
380
|
-
)
|
|
381
|
-
)
|
|
382
|
-
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
391
|
+
AND ${staleProcessingSql}
|
|
383
392
|
`),
|
|
393
|
+
// Requeues one row listRecoverableProcessing returned, so the caller
|
|
394
|
+
// knows exactly which rows it requeued and announces only those. The
|
|
395
|
+
// sweep runs synchronously, so nothing changes between listing and
|
|
396
|
+
// requeue; re-checking the same rule here is a safeguard that keeps
|
|
397
|
+
// this statement from ever taking a row the listing would not.
|
|
384
398
|
recoverProcessingBefore: db.prepare(`
|
|
385
399
|
UPDATE queue
|
|
386
400
|
SET status = 'pending',
|
|
@@ -390,14 +404,11 @@ export function createMessageRepository(db) {
|
|
|
390
404
|
owner_lease_expires_at = NULL,
|
|
391
405
|
owner_last_claimed_at = NULL,
|
|
392
406
|
retry_count = retry_count + 1
|
|
393
|
-
WHERE
|
|
407
|
+
WHERE id = @id
|
|
408
|
+
AND status = 'processing'
|
|
394
409
|
AND COALESCE(kind, '') != 'continuation'
|
|
395
410
|
AND consumed_at IS NULL
|
|
396
|
-
AND
|
|
397
|
-
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
398
|
-
OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
|
|
399
|
-
)
|
|
400
|
-
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
411
|
+
AND ${staleProcessingSql}
|
|
401
412
|
`),
|
|
402
413
|
// Dead-worker recovery: the owner's process is provably gone, so its
|
|
403
414
|
// in-flight rows go back to pending — owner intact (the primer
|
|
@@ -467,17 +478,7 @@ export function createMessageRepository(db) {
|
|
|
467
478
|
FROM queue
|
|
468
479
|
WHERE status = 'processing'
|
|
469
480
|
AND kind = 'continuation'
|
|
470
|
-
AND
|
|
471
|
-
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
472
|
-
OR (
|
|
473
|
-
@ceilingBefore IS NOT NULL
|
|
474
|
-
AND MAX(
|
|
475
|
-
COALESCE(processing_at, timestamp),
|
|
476
|
-
COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
|
|
477
|
-
) < @ceilingBefore
|
|
478
|
-
)
|
|
479
|
-
)
|
|
480
|
-
AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
|
|
481
|
+
AND ${staleProcessingSql}
|
|
481
482
|
`),
|
|
482
483
|
listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
483
484
|
// Authoritative "is a turn in flight" signal for the conversation list.
|
|
@@ -47,6 +47,8 @@ export function createSubagentTimelineStatements(db) {
|
|
|
47
47
|
// The column list every run read uses, so the header travels with the
|
|
48
48
|
// run wherever runs are listed (conversation payload, in-flight snapshot).
|
|
49
49
|
subagentRunSelectColumns,
|
|
50
|
+
// A run that runs again (a resumed agent): no end time while it runs.
|
|
51
|
+
reopenSubagentRun: db.prepare(`UPDATE subagent_runs SET status = 'running', updated_at = ?, completed_at = NULL WHERE id = ?`),
|
|
50
52
|
updateSubagentRunHeader: null,
|
|
51
53
|
setSubagentRunTruncated: null,
|
|
52
54
|
listChildSubagentRuns: null,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
|
|
4
4
|
import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
|
|
5
5
|
import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
|
|
6
|
+
import { closedQuestionAnswer, questionAcceptsFreeText } from '../../shared/question-answer-rule.mjs';
|
|
6
7
|
import {
|
|
7
8
|
extractRequestedSchema,
|
|
8
9
|
normalizeSchema,
|
|
@@ -35,19 +36,39 @@ function firstKnownValue(source, keyPaths = []) {
|
|
|
35
36
|
return null;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function parseRowJson(value) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(String(value || ''));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
/**
|
|
39
48
|
* A stored question row that is this relay's Allow / Deny card for one of its
|
|
40
49
|
* own agent's calls to another relay (remote-relay-caller-context.mjs). The
|
|
41
50
|
* context lives in the row's request envelope.
|
|
42
51
|
*/
|
|
43
52
|
function isRemoteRelayApprovalRow(row) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
const envelope = parseRowJson(row?.request);
|
|
54
|
+
return envelope ? isRemoteRelayApprovalQuestion(envelope) : false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The choices of a stored card that takes no typed text (approval and
|
|
59
|
+
* permission cards, and a card with choices that stored no flag, as the
|
|
60
|
+
* runtime's formatQuestionRow reads it for the card view), or null for a
|
|
61
|
+
* card that takes any reply.
|
|
62
|
+
*/
|
|
63
|
+
function closedQuestionOfRow(row) {
|
|
64
|
+
const envelope = parseRowJson(row?.request);
|
|
65
|
+
const parsedChoices = parseRowJson(row?.choices);
|
|
66
|
+
const choices = Array.isArray(parsedChoices) ? parsedChoices.map((choice) => String(choice ?? '')) : [];
|
|
67
|
+
const allowFreeform = envelope && typeof envelope === 'object' && typeof envelope.allowFreeform === 'boolean'
|
|
68
|
+
? envelope.allowFreeform
|
|
69
|
+
: null;
|
|
70
|
+
if (questionAcceptsFreeText({ allowFreeform, choices })) return null;
|
|
71
|
+
return { choices, multiSelect: envelope?.context?.multiSelect === true };
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
function extractContinuationOwnership(rawRequest) {
|
|
@@ -297,6 +318,19 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
297
318
|
flatText = summarizeStructuredAnswer(schema, structured) || flatText;
|
|
298
319
|
} else if (!flatText) {
|
|
299
320
|
return res.status(400).json({ error: 'Empty answer' });
|
|
321
|
+
} else {
|
|
322
|
+
// A card that takes no typed text is answered by its choices only.
|
|
323
|
+
// Anything else is refused and the card stays open: it must not pass
|
|
324
|
+
// for an answer (an approval reader would take it for a denial).
|
|
325
|
+
const closed = closedQuestionOfRow(row);
|
|
326
|
+
if (closed) {
|
|
327
|
+
const accepted = closedQuestionAnswer(closed, flatText);
|
|
328
|
+
if (!accepted) {
|
|
329
|
+
console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=not-a-choice`);
|
|
330
|
+
return res.status(400).json({ error: 'This question takes one of its choices, not typed text', choices: closed.choices });
|
|
331
|
+
}
|
|
332
|
+
flatText = accepted;
|
|
333
|
+
}
|
|
300
334
|
}
|
|
301
335
|
|
|
302
336
|
const result = askUserRoutingService.routeAnswer({
|
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
sanitizeRunHeaderPatch,
|
|
65
65
|
TIMELINE_READ_DEFAULT_LIMIT,
|
|
66
66
|
} from '../services/subagent-timeline-service.mjs';
|
|
67
|
-
import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
67
|
+
import { SUBAGENT_TASK_TYPES, isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
68
68
|
import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
|
|
69
69
|
import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
|
|
70
70
|
import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
|
|
@@ -1431,6 +1431,9 @@ function isOpenAIImageAttachment(raw) {
|
|
|
1431
1431
|
export function resolveOpenAIImageEditAttachment(rawAttachments, {
|
|
1432
1432
|
maxImageBytes = 20 * 1024 * 1024,
|
|
1433
1433
|
allowedRootPath = '',
|
|
1434
|
+
// Folders of earlier versions that uploads are still read from: only a
|
|
1435
|
+
// sha256-named blob directly inside one counts.
|
|
1436
|
+
allowedBlobRootPaths = [],
|
|
1434
1437
|
allowedGeneratedImagesRootPath = '',
|
|
1435
1438
|
} = {}) {
|
|
1436
1439
|
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
|
@@ -1451,8 +1454,11 @@ export function resolveOpenAIImageEditAttachment(rawAttachments, {
|
|
|
1451
1454
|
const filePath = String(firstImage.path || '').trim();
|
|
1452
1455
|
if (!filePath) throw new Error('Image attachment is missing file path or data URL');
|
|
1453
1456
|
const allowedRoot = String(allowedRootPath || '').trim();
|
|
1457
|
+
const blobRoots = (Array.isArray(allowedBlobRootPaths) ? allowedBlobRootPaths : [])
|
|
1458
|
+
.map((root) => String(root || '').trim())
|
|
1459
|
+
.filter(Boolean);
|
|
1454
1460
|
const generatedImagesRoot = String(allowedGeneratedImagesRootPath || '').trim();
|
|
1455
|
-
if (allowedRoot || generatedImagesRoot) {
|
|
1461
|
+
if (allowedRoot || blobRoots.length || generatedImagesRoot) {
|
|
1456
1462
|
const resolvedPath = path.resolve(filePath);
|
|
1457
1463
|
const isWithinRoot = (rootPath) => {
|
|
1458
1464
|
if (!rootPath) return false;
|
|
@@ -1467,7 +1473,11 @@ export function resolveOpenAIImageEditAttachment(rawAttachments, {
|
|
|
1467
1473
|
&& relativeParts[0] !== '..'
|
|
1468
1474
|
&& relativeParts[1] === 'generated-images';
|
|
1469
1475
|
})();
|
|
1470
|
-
|
|
1476
|
+
const isOldUploadBlob = blobRoots.some((root) => (
|
|
1477
|
+
path.dirname(resolvedPath) === path.resolve(root)
|
|
1478
|
+
&& /^[a-f0-9]{64}$/.test(path.basename(resolvedPath))
|
|
1479
|
+
));
|
|
1480
|
+
if (!isWithinRoot(allowedRoot) && !isOldUploadBlob && !isGeneratedImagePath) {
|
|
1471
1481
|
throw new Error('Image attachment path is outside the upload directory');
|
|
1472
1482
|
}
|
|
1473
1483
|
}
|
|
@@ -1747,6 +1757,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1747
1757
|
MAX_UPLOAD_BYTES,
|
|
1748
1758
|
MAX_UPLOAD_ATTACHMENTS,
|
|
1749
1759
|
uploadsDir,
|
|
1760
|
+
uploadReadDirs = [uploadsDir],
|
|
1750
1761
|
MAX_REPO_TREE_NODES,
|
|
1751
1762
|
MAX_REQUEUE_RETRIES,
|
|
1752
1763
|
MAX_IMAGE_DATA_URL_LENGTH,
|
|
@@ -1896,6 +1907,21 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1896
1907
|
return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
|
|
1897
1908
|
}
|
|
1898
1909
|
|
|
1910
|
+
// A conversation's sub-agent run by its task id. A Claude agent resumed by
|
|
1911
|
+
// a new message keeps its task id, and the CLI names the call that resumed
|
|
1912
|
+
// it (SendMessage) as the task's tool use, an id no run row carries: its
|
|
1913
|
+
// run is the one the task id leads to.
|
|
1914
|
+
function subagentRunOfTask(conversationId, taskId) {
|
|
1915
|
+
const conversation = String(conversationId || '').trim();
|
|
1916
|
+
const task = String(taskId || '').trim().slice(0, 200);
|
|
1917
|
+
if (!conversation || !task) return null;
|
|
1918
|
+
try {
|
|
1919
|
+
return stmts.getSubagentRunByTaskId?.get?.(conversation, task) || null;
|
|
1920
|
+
} catch {
|
|
1921
|
+
return null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1899
1925
|
// Stop is offered while a run is running: a background run through its
|
|
1900
1926
|
// task, a foreground run through the sub-agent cancel route.
|
|
1901
1927
|
function isSubagentRunStoppable(run) {
|
|
@@ -4791,9 +4817,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4791
4817
|
? (pinnedGrokModel || String(configuredGrok?.model || '').trim())
|
|
4792
4818
|
: '';
|
|
4793
4819
|
// Claude Cloud conversations resolve models against the cloud provider's
|
|
4794
|
-
// own list (plain ids, no "[1m]" tier). The
|
|
4795
|
-
// session's
|
|
4796
|
-
//
|
|
4820
|
+
// own list (plain ids, no "[1m]" tier). The first message creates the
|
|
4821
|
+
// cloud session with the conversation's model; a later change is applied
|
|
4822
|
+
// by the worker before the next message (see below).
|
|
4797
4823
|
const configuredClaudeCloud = runtimeUsesClaudeCloud ? getClaudeCloudProviderSettings() : null;
|
|
4798
4824
|
const pinnedClaudeCloudModel = runtimeUsesClaudeCloud
|
|
4799
4825
|
? stripModelTierSuffix(existingRuntimeSession?.provider_model)
|
|
@@ -6971,6 +6997,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6971
6997
|
editAttachment = resolveOpenAIImageEditAttachment(requestBody.attachments, {
|
|
6972
6998
|
maxImageBytes: MAX_UPLOAD_BYTES,
|
|
6973
6999
|
allowedRootPath: uploadsDir,
|
|
7000
|
+
allowedBlobRootPaths: uploadReadDirs.slice(1),
|
|
6974
7001
|
allowedGeneratedImagesRootPath: resolveSessionStateRoot?.() || '',
|
|
6975
7002
|
});
|
|
6976
7003
|
} catch (error) {
|
|
@@ -7830,6 +7857,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7830
7857
|
sessionActivityTracker?.noteTaskSettled?.(conversationId, {
|
|
7831
7858
|
taskId,
|
|
7832
7859
|
toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
|
|
7860
|
+
// Which run ended (a resumed agent runs again under the same task id).
|
|
7861
|
+
...(req.body?.taskRunId ? { taskRunId: String(req.body.taskRunId).trim().slice(0, 200) } : {}),
|
|
7833
7862
|
status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
|
|
7834
7863
|
summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
|
|
7835
7864
|
exitCode: exitCode ?? null,
|
|
@@ -7886,6 +7915,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7886
7915
|
// The spawning tool_use id: the sub-agent run this task is. The panel
|
|
7887
7916
|
// opens the run's viewer from it.
|
|
7888
7917
|
toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
|
|
7918
|
+
// Which run of the task this is (the SDK's run id): a resumed agent
|
|
7919
|
+
// keeps its task id and runs again under a new one.
|
|
7920
|
+
taskRunId: String(task?.taskRunId || '').trim().slice(0, 200) || undefined,
|
|
7889
7921
|
// The console (shell and Monitor tasks): the file holding the
|
|
7890
7922
|
// output, how it is obtained ('none': the provider has none to give).
|
|
7891
7923
|
...taskOutputFields(task),
|
|
@@ -7894,6 +7926,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7894
7926
|
exitCode: wholeNumber(task?.exitCode),
|
|
7895
7927
|
}))
|
|
7896
7928
|
.filter((task) => task.taskId);
|
|
7929
|
+
// A resumed agent's row names the call that resumed it, which no run
|
|
7930
|
+
// carries: it opens the run of its task instead (a worker that remembers
|
|
7931
|
+
// the spawn sends that already).
|
|
7932
|
+
const runConversationId = resolveTaskConversationId(conversationId);
|
|
7933
|
+
for (const task of tasks) {
|
|
7934
|
+
if (!task.toolUseId || !SUBAGENT_TASK_TYPES.has(task.taskType) || stmts.getSubagentRun?.get?.(task.toolUseId)) continue;
|
|
7935
|
+
const run = subagentRunOfTask(runConversationId, task.taskId);
|
|
7936
|
+
if (run) task.toolUseId = String(run.id);
|
|
7937
|
+
}
|
|
7897
7938
|
// The first path a task names wins: a later post cannot point a live
|
|
7898
7939
|
// task's console at another file.
|
|
7899
7940
|
for (const task of tasks) {
|
|
@@ -8563,12 +8604,14 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8563
8604
|
// Fenced by the run row, not by an open turn: a background agent keeps
|
|
8564
8605
|
// writing after its turn ended, and its entries belong to that turn. A run
|
|
8565
8606
|
// the relay does not know yet but whose parent it does (an agent spawned
|
|
8566
|
-
// inside another) is created under the parent's turn;
|
|
8567
|
-
//
|
|
8607
|
+
// inside another) is created under the parent's turn; a run of a task the
|
|
8608
|
+
// relay knows by its task id (a resumed agent: same task, new tool use) is
|
|
8609
|
+
// that task's run; anything else is a 404 the worker retries once the spawn
|
|
8610
|
+
// has landed.
|
|
8568
8611
|
app.post('/api/subagent-timeline', auth, (req, res) => {
|
|
8569
8612
|
touchCli();
|
|
8570
8613
|
const conversationId = String(req.body?.conversationId || '').trim();
|
|
8571
|
-
|
|
8614
|
+
let runId = sanitizeSubagentRunId(req.body?.subagentRunId);
|
|
8572
8615
|
if (!conversationId || !runId) {
|
|
8573
8616
|
return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
|
|
8574
8617
|
}
|
|
@@ -8577,6 +8620,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8577
8620
|
}
|
|
8578
8621
|
const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
|
|
8579
8622
|
let run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
8623
|
+
// A finished run that a later run of its task (posted under another id)
|
|
8624
|
+
// sets running again: the relay marks where it resumed, once per id.
|
|
8625
|
+
let resumedNote = null;
|
|
8626
|
+
if (!run) {
|
|
8627
|
+
run = subagentRunOfTask(conversationId, req.body?.taskId || rawHeader.taskId);
|
|
8628
|
+
if (run && isTerminalSubagentRunStatus(normalizeSubagentRunStatus(run.status))
|
|
8629
|
+
&& rawHeader.status && normalizeSubagentRunStatus(rawHeader.status) === 'running') {
|
|
8630
|
+
resumedNote = { key: `relay:resumed:${runId}`, kind: 'note', text: 'Resumed with a new message', metadata: { resumed: true } };
|
|
8631
|
+
}
|
|
8632
|
+
if (run) runId = String(run.id);
|
|
8633
|
+
}
|
|
8580
8634
|
let created = false;
|
|
8581
8635
|
if (!run) {
|
|
8582
8636
|
const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
|
|
@@ -8599,7 +8653,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
8599
8653
|
if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
|
|
8600
8654
|
let stored;
|
|
8601
8655
|
try {
|
|
8602
|
-
|
|
8656
|
+
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
|
|
8657
|
+
stored = subagentTimeline.applyEntries(run, resumedNote ? [resumedNote, ...entries] : entries);
|
|
8603
8658
|
} catch (error) {
|
|
8604
8659
|
return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
|
|
8605
8660
|
}
|
|
@@ -4,9 +4,46 @@
|
|
|
4
4
|
// listener itself has no routes and no way to register anything. Creating or
|
|
5
5
|
// closing a preview therefore always requires the relay token, even though the
|
|
6
6
|
// resulting URL is public.
|
|
7
|
+
//
|
|
8
|
+
// Conversation scope. A preview's token is its only credential, so an agent's
|
|
9
|
+
// preview tool must not see or close another conversation's. A request that
|
|
10
|
+
// names a conversation — the `conversationId` query, else the
|
|
11
|
+
// `X-Relay-Conversation-Id` header every worker and the OAR MCP server send —
|
|
12
|
+
// is scoped to it: GET lists only its previews, DELETE refuses another
|
|
13
|
+
// conversation's with a 403. Only a request with neither (the web UI's
|
|
14
|
+
// Settings → Previews) gets the relay-wide view. Agent-side callers present the
|
|
15
|
+
// same relay token as the owner, so this is a boundary for the tools, not
|
|
16
|
+
// against a caller that deliberately leaves both out.
|
|
7
17
|
|
|
8
18
|
import { validateStaticRoot } from '../services/preview-static-handler.mjs';
|
|
9
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The conversation ids a named scope covers: the id itself, and — when it
|
|
22
|
+
* names a conversation by its id or by its session id — that conversation's id
|
|
23
|
+
* and session id. They differ after a placeholder rekey, and a worker's or
|
|
24
|
+
* tool server's own identity is the session id. A lookup that fails narrows
|
|
25
|
+
* the scope to the named id rather than failing the request.
|
|
26
|
+
*/
|
|
27
|
+
export function createPreviewScopeResolver({
|
|
28
|
+
getConversation = () => null,
|
|
29
|
+
getConversationBySdkSessionId = () => null,
|
|
30
|
+
} = {}) {
|
|
31
|
+
return function resolveConversationScope(id) {
|
|
32
|
+
const named = String(id || '').trim();
|
|
33
|
+
if (!named) return [];
|
|
34
|
+
const ids = new Set([named]);
|
|
35
|
+
let conversation = null;
|
|
36
|
+
try {
|
|
37
|
+
conversation = getConversation(named) || getConversationBySdkSessionId(named) || null;
|
|
38
|
+
} catch {}
|
|
39
|
+
for (const value of [conversation?.id, conversation?.sdk_session_id]) {
|
|
40
|
+
const text = String(value || '').trim();
|
|
41
|
+
if (text) ids.add(text);
|
|
42
|
+
}
|
|
43
|
+
return [...ids];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
10
47
|
export function registerPreviewRoutes(app, deps) {
|
|
11
48
|
const {
|
|
12
49
|
auth,
|
|
@@ -20,6 +57,8 @@ export function registerPreviewRoutes(app, deps) {
|
|
|
20
57
|
// publishes stay panel-only — the transcript records the conversation, not
|
|
21
58
|
// panel actions).
|
|
22
59
|
recordPreviewCard = () => {},
|
|
60
|
+
// (scope id) => the conversation ids it covers; createPreviewScopeResolver.
|
|
61
|
+
resolveConversationScope = (id) => (id ? [id] : []),
|
|
23
62
|
} = deps;
|
|
24
63
|
|
|
25
64
|
function laneUnavailable(res) {
|
|
@@ -36,13 +75,27 @@ export function registerPreviewRoutes(app, deps) {
|
|
|
36
75
|
});
|
|
37
76
|
}
|
|
38
77
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
78
|
+
// The conversation ids a request is scoped to, or null for the owner's
|
|
79
|
+
// view. The query wins over the header: the header is the worker's own
|
|
80
|
+
// session, while a worker can be handed a row of another conversation (a
|
|
81
|
+
// placeholder rekey, or the shared fallback of the worker's pending lookup)
|
|
82
|
+
// and its tool names the conversation of the turn it is running.
|
|
83
|
+
function requestScope(req) {
|
|
84
|
+
const named = String(req.query?.conversationId || '').trim()
|
|
85
|
+
|| String(req.headers?.['x-relay-conversation-id'] || '').trim();
|
|
86
|
+
if (!named) return null;
|
|
87
|
+
const ids = resolveConversationScope(named);
|
|
88
|
+
return Array.isArray(ids) && ids.length ? ids : [named];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// GET /api/previews — one conversation's live previews when scoped (see the
|
|
92
|
+
// top of the file), else every one. Unscoped is what the settings "Live
|
|
93
|
+
// previews" list uses: nothing expires on its own, so there has to be one
|
|
94
|
+
// place that shows everything.
|
|
42
95
|
app.get('/api/previews', auth, (req, res) => {
|
|
43
|
-
const
|
|
44
|
-
const previews =
|
|
45
|
-
? previewRegistry.
|
|
96
|
+
const scope = requestScope(req);
|
|
97
|
+
const previews = scope
|
|
98
|
+
? previewRegistry.list().filter((entry) => scope.includes(entry.conversationId))
|
|
46
99
|
: previewRegistry.list();
|
|
47
100
|
res.json({
|
|
48
101
|
previews,
|
|
@@ -107,8 +160,21 @@ export function registerPreviewRoutes(app, deps) {
|
|
|
107
160
|
});
|
|
108
161
|
|
|
109
162
|
// DELETE /api/previews/:token — the link stops resolving immediately. The dev
|
|
110
|
-
// server behind it is untouched; it is a separate background task.
|
|
163
|
+
// server behind it is untouched; it is a separate background task. A scoped
|
|
164
|
+
// request may close only its own conversation's previews.
|
|
111
165
|
app.delete('/api/previews/:token', auth, (req, res) => {
|
|
166
|
+
const scope = requestScope(req);
|
|
167
|
+
if (scope) {
|
|
168
|
+
const token = String(req.params?.token || '').trim();
|
|
169
|
+
// list() also holds the stored previews a restart could not check yet.
|
|
170
|
+
const target = previewRegistry.list().find((entry) => entry.token === token);
|
|
171
|
+
if (target && !scope.includes(target.conversationId)) {
|
|
172
|
+
return res.status(403).json({
|
|
173
|
+
error: 'This preview belongs to another conversation; a conversation can close only its own previews. '
|
|
174
|
+
+ 'The owner can close it in Settings → Previews.',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
112
178
|
const closed = previewRegistry.close(req.params?.token);
|
|
113
179
|
if (!closed.ok) return res.status(closed.status || 404).json({ error: closed.error });
|
|
114
180
|
res.json({ ok: true, preview: closed.preview });
|