@oar-sh/oar 0.9.2 → 0.9.4
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 +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The `context` block a relay question card carries: who asked, why, for which
|
|
2
|
+
// queue row, and the few presentation hints the card renders from. Workers
|
|
3
|
+
// send it on POST /api/relay-question; it is stored inside the question's
|
|
4
|
+
// request envelope and served back to the client. Anything not listed here is
|
|
5
|
+
// dropped, so a new hint the client reads must be added here too (the
|
|
6
|
+
// multi-select flag was once silently stripped this way).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {unknown} rawContext
|
|
10
|
+
* @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeRelayQuestionContext(rawContext, {
|
|
13
|
+
normalizeRelayMode = (mode) => String(mode || '').trim().toLowerCase() || null,
|
|
14
|
+
defaultRelayMode = 'agent',
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
|
|
17
|
+
const context = {};
|
|
18
|
+
if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
|
|
19
|
+
context.source = rawContext.source.trim().slice(0, 64);
|
|
20
|
+
}
|
|
21
|
+
if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
|
|
22
|
+
context.rationale = rawContext.rationale.trim().slice(0, 240);
|
|
23
|
+
}
|
|
24
|
+
if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
|
|
25
|
+
context.queueMessageId = rawContext.queueMessageId.trim();
|
|
26
|
+
}
|
|
27
|
+
if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
|
|
28
|
+
context.conversationId = rawContext.conversationId.trim();
|
|
29
|
+
}
|
|
30
|
+
if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
|
|
31
|
+
context.relayMode = normalizeRelayMode(rawContext.relayMode) || defaultRelayMode;
|
|
32
|
+
}
|
|
33
|
+
// The card renders checkmarks instead of one-shot buttons when several
|
|
34
|
+
// answers are allowed (Claude's AskUserQuestion `multiSelect`, or the
|
|
35
|
+
// Copilot worker's wording heuristic). Only an explicit `true` is kept.
|
|
36
|
+
if (rawContext.multiSelect === true) context.multiSelect = true;
|
|
37
|
+
// Providers whose ask tool cannot always say so (Copilot) mark their choice
|
|
38
|
+
// cards with this: the card then offers a "Select several" switch, so the
|
|
39
|
+
// user can pick several answers even when the model did not flag it.
|
|
40
|
+
if (rawContext.allowMultiSelect === true) context.allowMultiSelect = true;
|
|
41
|
+
// Claude's short question label ("Workers", "Visibility").
|
|
42
|
+
if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
|
|
43
|
+
context.header = rawContext.header.trim().slice(0, 120);
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(context).length ? context : null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
REMOTE_RELAY_APPROVAL_SOURCE,
|
|
7
|
+
REMOTE_RELAY_ERROR_CODES,
|
|
8
|
+
REMOTE_RELAY_PROVIDERS,
|
|
9
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
10
|
+
import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../../shared/question-timeout.mjs';
|
|
11
|
+
import { createQuestionRepository } from '../repositories/question-repository.mjs';
|
|
12
|
+
import { pickLiveTurnRowId } from './live-turn-picker.mjs';
|
|
13
|
+
import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
|
|
14
|
+
|
|
15
|
+
// The runtime side of the remote-relay dispatcher: who is calling (the
|
|
16
|
+
// conversation, its current turn, provider, model, relay mode and hop count)
|
|
17
|
+
// and the approval card that write actions need in ask/plan mode.
|
|
18
|
+
//
|
|
19
|
+
// A tool call arrives while the calling session's turn is processing, so the
|
|
20
|
+
// turn is that conversation's live processing queue row. Its id is also the
|
|
21
|
+
// id of the user message behind it (POST /api/message writes both with one
|
|
22
|
+
// id), which is where an agent-sent prompt keeps its `origin` and hop count.
|
|
23
|
+
//
|
|
24
|
+
// A background continuation turn (queue kind 'continuation') has no user
|
|
25
|
+
// message behind it; it continues work an earlier turn started, so it counts
|
|
26
|
+
// the hops of the latest user message before it.
|
|
27
|
+
//
|
|
28
|
+
// The approval card is an ordinary relay question on that row, written the
|
|
29
|
+
// way POST /api/relay-question writes one: same columns, same socket event and
|
|
30
|
+
// push, so it renders like any ask_user card and the stale-recovery and turn
|
|
31
|
+
// ceiling exemptions for a turn waiting on a question apply to it. Its context
|
|
32
|
+
// source (REMOTE_RELAY_APPROVAL_SOURCE) keeps other relays' agents away from it.
|
|
33
|
+
|
|
34
|
+
const LOG_PREFIX = '[remote-relays]';
|
|
35
|
+
const APPROVAL_HEADER = 'Remote relay';
|
|
36
|
+
const APPROVAL_ALLOW = 'Allow';
|
|
37
|
+
const APPROVAL_DENY = 'Deny';
|
|
38
|
+
const EXCERPT_MAX = 200;
|
|
39
|
+
|
|
40
|
+
function toText(value) {
|
|
41
|
+
return String(value ?? '').trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shortId(value) {
|
|
45
|
+
return toText(value).slice(0, 8) || '-';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultNormalizeRelayMode(mode) {
|
|
49
|
+
return toText(mode).toLowerCase() || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseJson(value, fallback) {
|
|
53
|
+
if (!value) return fallback;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(value);
|
|
56
|
+
} catch {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Enough of the runtime's formatQuestionRow for the card and the tests. */
|
|
62
|
+
function defaultFormatQuestionRow(row) {
|
|
63
|
+
if (!row) return null;
|
|
64
|
+
const envelope = parseJson(row.request, null);
|
|
65
|
+
return {
|
|
66
|
+
id: row.id,
|
|
67
|
+
queueId: row.queue_id,
|
|
68
|
+
conversationId: row.conversation_id,
|
|
69
|
+
sdkSessionId: row.sdk_session_id || null,
|
|
70
|
+
messageId: row.message_id,
|
|
71
|
+
mode: row.relay_mode,
|
|
72
|
+
prompt: row.prompt,
|
|
73
|
+
choices: parseJson(row.choices, []),
|
|
74
|
+
context: envelope && typeof envelope === 'object' ? envelope.context || null : null,
|
|
75
|
+
allowFreeform: envelope?.allowFreeform ?? false,
|
|
76
|
+
status: row.status,
|
|
77
|
+
answer: row.answer || null,
|
|
78
|
+
createdAt: row.created_at,
|
|
79
|
+
answeredAt: row.answered_at || null,
|
|
80
|
+
expiresAt: row.expires_at,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function excerpt(value) {
|
|
85
|
+
const text = toText(value).replace(/\s+/g, ' ');
|
|
86
|
+
return text.length > EXCERPT_MAX ? `${text.slice(0, EXCERPT_MAX - 1)}…` : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const APPROVAL_VERBS = Object.freeze({
|
|
90
|
+
send: 'send a prompt',
|
|
91
|
+
create_session: 'start a new session',
|
|
92
|
+
answer_question: 'answer a question',
|
|
93
|
+
stop: 'stop the running turn',
|
|
94
|
+
archive: 'archive a session',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How the remote turn would run, as far as this relay knows it: the mode (the
|
|
99
|
+
* requested one, else the calling session's, which the dispatcher passes on),
|
|
100
|
+
* the provider a new session gets (requested, else the caller's), and the
|
|
101
|
+
* model and working directory when the agent asked for them.
|
|
102
|
+
*/
|
|
103
|
+
function describeRemoteTurn(action, args, caller) {
|
|
104
|
+
const parts = [];
|
|
105
|
+
const mode = toText(args.mode).toLowerCase() || toText(caller?.mode).toLowerCase();
|
|
106
|
+
if (mode) parts.push(`Mode: ${mode}`);
|
|
107
|
+
if (action === 'create_session') {
|
|
108
|
+
const requested = toText(args.provider).toLowerCase();
|
|
109
|
+
const callerProvider = toText(caller?.provider).toLowerCase();
|
|
110
|
+
const provider = requested || (REMOTE_RELAY_PROVIDERS.includes(callerProvider) ? callerProvider : 'github');
|
|
111
|
+
parts.push(`Provider: ${provider}`);
|
|
112
|
+
}
|
|
113
|
+
if (toText(args.model)) parts.push(`Model: ${excerpt(args.model)}`);
|
|
114
|
+
if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)}`);
|
|
115
|
+
return parts.join(' · ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The card text: what the agent wants to do where, how the remote turn would
|
|
120
|
+
* run, then the prompt (or the answer) it wants to send, so the user decides
|
|
121
|
+
* on the actual words. `caller` is the calling session's context.
|
|
122
|
+
*/
|
|
123
|
+
export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
|
|
124
|
+
const name = toText(relay?.name) || 'a remote relay';
|
|
125
|
+
const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
|
|
126
|
+
const target = args.session ? ` (session ${shortId(args.session)})` : '';
|
|
127
|
+
const lines = [`Allow the agent to ${verb} on relay "${name}"${target}?`];
|
|
128
|
+
if (action === 'send' || action === 'create_session') {
|
|
129
|
+
const details = describeRemoteTurn(action, args, caller);
|
|
130
|
+
if (details) lines.push(details);
|
|
131
|
+
const text = excerpt(args.text);
|
|
132
|
+
if (text) lines.push(`“${text}”`);
|
|
133
|
+
} else if (action === 'answer_question') {
|
|
134
|
+
const answer = excerpt([...(Array.isArray(args.choices) ? args.choices : []), args.answer].filter(Boolean).join(', '));
|
|
135
|
+
if (answer) lines.push(`Answer: “${answer}”`);
|
|
136
|
+
}
|
|
137
|
+
return lines.join('\n\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createRemoteRelayCallerContext({
|
|
141
|
+
db,
|
|
142
|
+
repository,
|
|
143
|
+
questions = null,
|
|
144
|
+
resolveLiveTurnQueueRow = null,
|
|
145
|
+
formatQuestionRow = defaultFormatQuestionRow,
|
|
146
|
+
normalizeRelayMode = defaultNormalizeRelayMode,
|
|
147
|
+
defaultRelayMode = 'agent',
|
|
148
|
+
emit = () => {},
|
|
149
|
+
notifyQuestion = () => {},
|
|
150
|
+
uuid = nodeRandomUUID,
|
|
151
|
+
now = () => new Date(),
|
|
152
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
153
|
+
logger = console,
|
|
154
|
+
questionPollMs = 1000,
|
|
155
|
+
questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const questionStatements = questions || createQuestionRepository(db);
|
|
158
|
+
const getConversation = db.prepare(`SELECT id, title, preferred_relay_mode, preferred_model FROM conversations WHERE id = ?`);
|
|
159
|
+
const getRuntimeSession = db.prepare(`SELECT provider_type, provider_model, model FROM runtime_sessions WHERE conversation_id = ?`);
|
|
160
|
+
const listProcessingRows = db.prepare(`
|
|
161
|
+
SELECT id, kind, relay_mode, model, attempt_id, owner_sdk_session_id, timestamp, processing_at
|
|
162
|
+
FROM queue
|
|
163
|
+
WHERE conversation_id = ? AND status = 'processing'
|
|
164
|
+
ORDER BY COALESCE(processing_at, timestamp) ASC
|
|
165
|
+
`);
|
|
166
|
+
const getQueueRow = db.prepare(`SELECT * FROM queue WHERE id = ?`);
|
|
167
|
+
const getLatestUserMessage = db.prepare(`
|
|
168
|
+
SELECT id FROM messages WHERE conversation_id = ? AND role = 'user' ORDER BY timestamp DESC LIMIT 1
|
|
169
|
+
`);
|
|
170
|
+
const getLatestUserMessageAtOrBefore = db.prepare(`
|
|
171
|
+
SELECT id FROM messages WHERE conversation_id = ? AND role = 'user' AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1
|
|
172
|
+
`);
|
|
173
|
+
|
|
174
|
+
function normalizeMode(mode) {
|
|
175
|
+
return normalizeRelayMode(mode) || defaultRelayMode;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The live turn: the runtime's own picker when given, else the same rule. */
|
|
179
|
+
function liveTurnRow(conversationId, rows) {
|
|
180
|
+
if (!rows.length) return null;
|
|
181
|
+
if (typeof resolveLiveTurnQueueRow === 'function') {
|
|
182
|
+
const live = resolveLiveTurnQueueRow(conversationId);
|
|
183
|
+
const match = live && rows.find((row) => row.id === live.id);
|
|
184
|
+
if (match) return match;
|
|
185
|
+
}
|
|
186
|
+
if (rows.length === 1) return rows[0];
|
|
187
|
+
const liveId = pickLiveTurnRowId(rows.map((row) => {
|
|
188
|
+
const output = questionStatements.getLastOutputAtByQueueMessage?.get({ id: row.id }) || null;
|
|
189
|
+
return {
|
|
190
|
+
id: String(row.id),
|
|
191
|
+
lastOutputAtMs: Date.parse(String(output?.last_output_at || '')) || 0,
|
|
192
|
+
streamDone: Number(output?.stream_done || 0) === 1,
|
|
193
|
+
processingAtMs: Date.parse(String(row.processing_at || row.timestamp || '')) || 0,
|
|
194
|
+
};
|
|
195
|
+
}));
|
|
196
|
+
return rows.find((row) => row.id === liveId) || rows[0];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function originHops(messageId) {
|
|
200
|
+
const origin = messageId ? repository?.getMessageOrigin?.(messageId) : null;
|
|
201
|
+
const hops = Number(origin?.hops);
|
|
202
|
+
return Number.isInteger(hops) && hops >= 0 ? hops : 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A turn's hops: those of its own user message, or for a continuation row
|
|
207
|
+
* (no message of its own) those of the latest user message before it, the
|
|
208
|
+
* turn whose background work it continues.
|
|
209
|
+
*/
|
|
210
|
+
function rowHops(conversationId, row) {
|
|
211
|
+
if (toText(row.kind).toLowerCase() !== 'continuation') return originHops(row.id);
|
|
212
|
+
const at = toText(row.timestamp) || toText(row.processing_at);
|
|
213
|
+
const message = at
|
|
214
|
+
? getLatestUserMessageAtOrBefore.get(conversationId, at)
|
|
215
|
+
: getLatestUserMessage.get(conversationId);
|
|
216
|
+
return originHops(message?.id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `{ conversationId, title, provider, model, mode, processingRowId,
|
|
221
|
+
* attemptId, userMessageId, hops }` for the conversation making the call.
|
|
222
|
+
* `hops` is how many relays the current turn's prompt already crossed: 0
|
|
223
|
+
* for a human's message. A steered message folded into the live turn is
|
|
224
|
+
* part of what the agent is acting on, so the highest count among the
|
|
225
|
+
* conversation's processing rows wins (a background continuation counts as
|
|
226
|
+
* the turn it continues); without a turn (an off-turn call) the latest user
|
|
227
|
+
* message decides.
|
|
228
|
+
*/
|
|
229
|
+
function getCallerContext(conversationId) {
|
|
230
|
+
const id = toText(conversationId);
|
|
231
|
+
const conversation = getConversation.get(id) || null;
|
|
232
|
+
const runtime = getRuntimeSession.get(id) || null;
|
|
233
|
+
const rows = listProcessingRows.all(id);
|
|
234
|
+
const live = liveTurnRow(id, rows);
|
|
235
|
+
const hops = rows.length
|
|
236
|
+
? Math.max(...rows.map((row) => rowHops(id, row)))
|
|
237
|
+
: originHops(getLatestUserMessage.get(id)?.id);
|
|
238
|
+
return {
|
|
239
|
+
conversationId: id,
|
|
240
|
+
title: toText(conversation?.title),
|
|
241
|
+
provider: toText(runtime?.provider_type).toLowerCase() || 'github',
|
|
242
|
+
model: toText(live?.model) || toText(runtime?.provider_model) || toText(runtime?.model) || toText(conversation?.preferred_model) || '',
|
|
243
|
+
mode: normalizeMode(live?.relay_mode || conversation?.preferred_relay_mode),
|
|
244
|
+
processingRowId: live?.id || null,
|
|
245
|
+
attemptId: toText(live?.attempt_id) || null,
|
|
246
|
+
userMessageId: live?.id || null,
|
|
247
|
+
hops,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatQuestion(questionId) {
|
|
252
|
+
return formatQuestionRow(questionStatements.getQuestion.get(questionId));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function withdraw(questionId) {
|
|
256
|
+
try {
|
|
257
|
+
questionStatements.timeoutQuestion.run(questionId);
|
|
258
|
+
emit('relay_question_updated', { question: formatQuestion(questionId) });
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Puts an Allow / Deny card on the calling turn and waits for the user.
|
|
264
|
+
* Resolves `{ approved:true }`, or `{ approved:false, code?, error? }`:
|
|
265
|
+
* denied, timed out, cancelled with the turn, or no turn to ask on (fails
|
|
266
|
+
* closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
|
|
267
|
+
* lives (8 h by default); `signal` withdraws the card early.
|
|
268
|
+
*/
|
|
269
|
+
async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
|
|
270
|
+
const rowId = toText(callerContext?.processingRowId);
|
|
271
|
+
const row = rowId ? getQueueRow.get(rowId) : null;
|
|
272
|
+
if (!row || row.status !== 'processing') {
|
|
273
|
+
return {
|
|
274
|
+
approved: false,
|
|
275
|
+
code: REMOTE_RELAY_ERROR_CODES.noTurn,
|
|
276
|
+
error: 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const createdAt = now().toISOString();
|
|
281
|
+
const questionId = String(uuid());
|
|
282
|
+
const relayMode = normalizeMode(row.relay_mode);
|
|
283
|
+
const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
|
|
284
|
+
const context = sanitizeRelayQuestionContext({
|
|
285
|
+
source: REMOTE_RELAY_APPROVAL_SOURCE,
|
|
286
|
+
rationale: `Write actions on other relays need approval in ${relayMode} mode.`,
|
|
287
|
+
queueMessageId: row.id,
|
|
288
|
+
conversationId: toText(conversationId) || row.conversation_id,
|
|
289
|
+
relayMode,
|
|
290
|
+
header: APPROVAL_HEADER,
|
|
291
|
+
}, { normalizeRelayMode: normalizeMode, defaultRelayMode });
|
|
292
|
+
questionStatements.insertQuestion.run(
|
|
293
|
+
questionId,
|
|
294
|
+
row.id,
|
|
295
|
+
toText(conversationId) || row.conversation_id,
|
|
296
|
+
row.id,
|
|
297
|
+
relayMode,
|
|
298
|
+
prompt,
|
|
299
|
+
JSON.stringify([APPROVAL_ALLOW, APPROVAL_DENY]),
|
|
300
|
+
JSON.stringify({ request: null, context, allowFreeform: false }),
|
|
301
|
+
null,
|
|
302
|
+
toText(row.owner_sdk_session_id) || null,
|
|
303
|
+
null,
|
|
304
|
+
null,
|
|
305
|
+
null,
|
|
306
|
+
createdAt,
|
|
307
|
+
questionExpiresAt(createdAt, questionTimeoutMs),
|
|
308
|
+
toText(row.attempt_id) || null,
|
|
309
|
+
);
|
|
310
|
+
const question = formatQuestion(questionId);
|
|
311
|
+
try {
|
|
312
|
+
logger?.log?.(`${LOG_PREFIX} approval ${shortId(questionId)} conv=${shortId(row.conversation_id)} ${action} ${toText(relay?.name) || '-'}`);
|
|
313
|
+
} catch {}
|
|
314
|
+
try { emit('relay_question', { question }); } catch {}
|
|
315
|
+
try { void notifyQuestion(question); } catch {}
|
|
316
|
+
|
|
317
|
+
for (;;) {
|
|
318
|
+
const current = questionStatements.getQuestion.get(questionId);
|
|
319
|
+
if (!current) return { approved: false, error: 'The approval question was removed.' };
|
|
320
|
+
if (current.status === 'answered') {
|
|
321
|
+
const approved = toText(current.answer).toLowerCase() === APPROVAL_ALLOW.toLowerCase();
|
|
322
|
+
return approved ? { approved: true } : { approved: false };
|
|
323
|
+
}
|
|
324
|
+
if (current.status !== 'pending') {
|
|
325
|
+
return {
|
|
326
|
+
approved: false,
|
|
327
|
+
error: current.status === 'timed_out'
|
|
328
|
+
? 'The approval question timed out without an answer.'
|
|
329
|
+
: 'The approval question was cancelled (the turn ended).',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (signal?.aborted) {
|
|
333
|
+
withdraw(questionId);
|
|
334
|
+
return { approved: false, error: 'The tool call was abandoned before the user answered.' };
|
|
335
|
+
}
|
|
336
|
+
await sleep(questionPollMs);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { getCallerContext, requestApproval };
|
|
341
|
+
}
|