@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
|
@@ -15,40 +15,45 @@ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Resolves the instruction block for this relay, or '' when the preview lane
|
|
18
|
-
* is off. Preview settings only change on relay restart, so the
|
|
19
|
-
* cached: this must not become a per-turn HTTP call.
|
|
18
|
+
* is off. Preview settings only change on relay restart, so the lane facts are
|
|
19
|
+
* cached: this must not become a per-turn HTTP call. The block itself is
|
|
20
|
+
* rendered per call for `{ conversationId }`, the conversation it is sent to,
|
|
21
|
+
* so the API calls it teaches are scoped to that conversation.
|
|
20
22
|
*/
|
|
21
23
|
export function createPreviewInstructionsProvider({
|
|
22
24
|
api,
|
|
23
25
|
ttlMs = DEFAULT_TTL_MS,
|
|
24
26
|
now = () => Date.now(),
|
|
25
27
|
} = {}) {
|
|
26
|
-
|
|
28
|
+
// null: the lane is off; else its public base URL.
|
|
29
|
+
let cachedLane = null;
|
|
27
30
|
let cachedUntil = 0;
|
|
28
31
|
let inFlight = null;
|
|
29
32
|
|
|
30
33
|
async function load() {
|
|
31
34
|
const response = await api('GET', '/api/previews');
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
: '';
|
|
35
|
-
cachedText = text;
|
|
35
|
+
const lane = response?.enabled === true ? { publicBaseUrl: response?.publicBaseUrl || '' } : null;
|
|
36
|
+
cachedLane = lane;
|
|
36
37
|
cachedUntil = now() + ttlMs;
|
|
37
|
-
return
|
|
38
|
+
return lane;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
function render(lane, conversationId) {
|
|
42
|
+
return lane ? renderPreviewInstructionBlock({ publicBaseUrl: lane.publicBaseUrl, conversationId }) : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return async function getPreviewInstructions({ conversationId = '' } = {}) {
|
|
41
46
|
if (typeof api !== 'function') return '';
|
|
42
|
-
if (cachedUntil > now()) return
|
|
47
|
+
if (cachedUntil > now()) return render(cachedLane, conversationId);
|
|
43
48
|
if (!inFlight) {
|
|
44
49
|
// A failed lookup is deliberately not cached, and yields no block: an
|
|
45
50
|
// unreachable relay must neither lose the capability for the rest of the
|
|
46
51
|
// process nor advertise one that would 503.
|
|
47
52
|
inFlight = load()
|
|
48
|
-
.catch(() =>
|
|
53
|
+
.catch(() => null)
|
|
49
54
|
.finally(() => { inFlight = null; });
|
|
50
55
|
}
|
|
51
|
-
return inFlight;
|
|
56
|
+
return render(await inFlight, conversationId);
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -15,9 +15,9 @@ export const PREVIEW_TOOL_NAME = 'preview';
|
|
|
15
15
|
|
|
16
16
|
export const PREVIEW_TOOL_DESCRIPTION =
|
|
17
17
|
'Publish a local web server or a static directory on a public preview URL so '
|
|
18
|
-
+ 'the user can open it from any device, or list/close
|
|
19
|
-
+ 'this whenever the user wants to see, try, or share something
|
|
20
|
-
+ '— never ask them to open localhost or forward a port. '
|
|
18
|
+
+ 'the user can open it from any device, or list/close this conversation\'s '
|
|
19
|
+
+ 'previews. Use this whenever the user wants to see, try, or share something '
|
|
20
|
+
+ 'with a web UI — never ask them to open localhost or forward a port. '
|
|
21
21
|
+ 'For a dev server: call this FIRST with {action:"create", port} to get the '
|
|
22
22
|
+ 'basePath, then start the server configured to serve under that basePath '
|
|
23
23
|
+ '(Vite --base, Next basePath, Express mount prefix); the proxy forwards '
|
|
@@ -37,7 +37,7 @@ export const PREVIEW_TOOL_INPUT_SCHEMA = {
|
|
|
37
37
|
action: {
|
|
38
38
|
type: 'string',
|
|
39
39
|
enum: ['create', 'list', 'close'],
|
|
40
|
-
description: 'create: publish a port or directory. list: live previews. close: unpublish one.',
|
|
40
|
+
description: 'create: publish a port or directory. list: this conversation\'s live previews. close: unpublish one of them.',
|
|
41
41
|
},
|
|
42
42
|
port: {
|
|
43
43
|
type: 'integer',
|
|
@@ -114,10 +114,19 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
114
114
|
if (typeof api !== 'function') return { ok: false, error: 'Preview API unavailable in this worker' };
|
|
115
115
|
const parsed = validatePreviewToolInput(input);
|
|
116
116
|
if (!parsed.ok) return parsed;
|
|
117
|
+
// list and close reach only this conversation's previews: a preview's token
|
|
118
|
+
// is its only credential. The relay enforces the same scope for every
|
|
119
|
+
// request that names a conversation (routes/preview-routes.mjs). A create
|
|
120
|
+
// without one would land in no conversation's scope, so it is refused too.
|
|
121
|
+
const conversation = toText(conversationId);
|
|
122
|
+
const scope = `conversationId=${encodeURIComponent(conversation)}`;
|
|
123
|
+
if (!conversation) {
|
|
124
|
+
return { ok: false, error: 'This tool call is not bound to a conversation; it cannot publish, list or close previews' };
|
|
125
|
+
}
|
|
117
126
|
|
|
118
127
|
try {
|
|
119
128
|
if (parsed.action === 'list') {
|
|
120
|
-
const response = await api('GET',
|
|
129
|
+
const response = await api('GET', `/api/previews?${scope}`);
|
|
121
130
|
return {
|
|
122
131
|
ok: true,
|
|
123
132
|
enabled: response?.enabled === true,
|
|
@@ -138,7 +147,7 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
138
147
|
if (parsed.action === 'close') {
|
|
139
148
|
let token = parsed.token;
|
|
140
149
|
if (!token) {
|
|
141
|
-
const response = await api('GET', `/api/previews
|
|
150
|
+
const response = await api('GET', `/api/previews?${scope}`);
|
|
142
151
|
const live = response?.previews || [];
|
|
143
152
|
if (live.length === 0) return { ok: false, error: 'No live previews in this conversation' };
|
|
144
153
|
if (live.length > 1) {
|
|
@@ -150,12 +159,14 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
150
159
|
}
|
|
151
160
|
token = live[0].token;
|
|
152
161
|
}
|
|
153
|
-
|
|
162
|
+
// Another conversation's token is refused by the relay (403); its
|
|
163
|
+
// message comes back as the answer.
|
|
164
|
+
const response = await api('DELETE', `/api/previews/${token}?${scope}`);
|
|
154
165
|
return { ok: true, closed: { token, label: response?.preview?.label || null } };
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
const body = {
|
|
158
|
-
conversationId,
|
|
169
|
+
conversationId: conversation,
|
|
159
170
|
label: parsed.label || undefined,
|
|
160
171
|
...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
|
|
161
172
|
};
|
|
@@ -176,26 +187,33 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
176
187
|
: hint,
|
|
177
188
|
};
|
|
178
189
|
} catch (error) {
|
|
179
|
-
|
|
190
|
+
// A scope refusal (403) in its own words, without the HTTP wrapper.
|
|
191
|
+
const refusal = Number(error?.status) === 403 ? toText(error?.detail) : '';
|
|
192
|
+
return { ok: false, error: refusal || String(error?.message || error) };
|
|
180
193
|
}
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
/**
|
|
184
197
|
* The instruction block for providers without a custom-tool surface. Generated
|
|
185
198
|
* from the same description/schema strings the tool uses; keep any wording
|
|
186
|
-
* change up in the constants, never here.
|
|
199
|
+
* change up in the constants, never here. `conversationId` is the conversation
|
|
200
|
+
* the block is sent to: written into every call, so the model's natural call
|
|
201
|
+
* is the scoped one (without it, a plain GET would list the whole relay).
|
|
187
202
|
*/
|
|
188
|
-
export function renderPreviewInstructionBlock({ publicBaseUrl = '' } = {}) {
|
|
203
|
+
export function renderPreviewInstructionBlock({ publicBaseUrl = '', conversationId = '' } = {}) {
|
|
189
204
|
const base = toText(publicBaseUrl) || 'https://<preview-host>';
|
|
205
|
+
const id = toText(conversationId);
|
|
206
|
+
const conv = id ? JSON.stringify(id).slice(1, -1) : '<conv>';
|
|
207
|
+
const convQuery = id ? encodeURIComponent(id) : '<conv>';
|
|
190
208
|
return `## Preview servers
|
|
191
209
|
|
|
192
210
|
${PREVIEW_TOOL_DESCRIPTION}
|
|
193
211
|
|
|
194
212
|
There is no \`${PREVIEW_TOOL_NAME}\` tool on this provider — use the authenticated localhost API (same auth as /api/relay/shutdown):
|
|
195
213
|
|
|
196
|
-
- Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "
|
|
197
|
-
- Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "
|
|
198
|
-
- List: \`GET /api/previews
|
|
214
|
+
- Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "${conv}", "port": 5173, "label": "web app" }\` → \`{ "url": "${base}/test_<token>/", "basePath": "/test_<token>/" }\`. Register FIRST, then start the server under the returned basePath.
|
|
215
|
+
- Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "${conv}", "dir": "./dist", "label": "built site" }\` — the relay serves the directory itself. \`dir\` must live inside the conversation's workspace root.
|
|
216
|
+
- List this conversation's previews: \`GET /api/previews?conversationId=${convQuery}\`. Close one: \`DELETE /api/previews/:token?conversationId=${convQuery}\` — closes the link only, never 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).
|
|
199
217
|
|
|
200
218
|
A 503 with \`details\` means the preview lane is disabled or misconfigured — surface those details to the user. A 400 names exactly what was refused (relay-owned port, non-loopback host, dir outside the workspace root).`;
|
|
201
219
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What an answer to a question card means, in one place. The relay's answer
|
|
2
|
+
// route (a card that takes no typed text accepts only its choices), the card
|
|
3
|
+
// view (no reply box on such a card; server/public/app/question-answer-rule.mjs
|
|
4
|
+
// is its browser copy) and every reader of an approve / deny
|
|
5
|
+
// card (OAR's own remote-relay approvals, Claude Cloud's and Copilot's
|
|
6
|
+
// permission cards) apply the same rule: an answer is a choice when it equals
|
|
7
|
+
// the choice's label trimmed and in any case.
|
|
8
|
+
|
|
9
|
+
function answerKey(value) {
|
|
10
|
+
return String(value ?? '').trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The card's own label for `answer`, or null when it is none of `choices`. */
|
|
14
|
+
export function matchQuestionChoice(answer, choices) {
|
|
15
|
+
const key = answerKey(answer);
|
|
16
|
+
if (!key || !Array.isArray(choices)) return null;
|
|
17
|
+
for (const choice of choices) {
|
|
18
|
+
const label = String(choice ?? '').trim();
|
|
19
|
+
if (label && answerKey(label) === key) return label;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An approve / deny card's answer: 'approve' for its approve choice, 'deny'
|
|
26
|
+
* for its deny choice, null for anything else. A caller decides what null
|
|
27
|
+
* means for its card; it is never the user's plain Deny.
|
|
28
|
+
*/
|
|
29
|
+
export function readApprovalAnswer(answer, { approve, deny }) {
|
|
30
|
+
const key = answerKey(answer);
|
|
31
|
+
if (!key) return null;
|
|
32
|
+
if (key === answerKey(approve)) return 'approve';
|
|
33
|
+
if (key === answerKey(deny)) return 'deny';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a card takes typed text. A card with choices takes it only when it
|
|
39
|
+
* says so (`allowFreeform: true`); a missing flag counts as false there, as
|
|
40
|
+
* the create route and the relay's question rows read it. Without choices a
|
|
41
|
+
* card always does: it could not be answered otherwise.
|
|
42
|
+
*/
|
|
43
|
+
export function questionAcceptsFreeText(question) {
|
|
44
|
+
const choices = Array.isArray(question?.choices) ? question.choices : [];
|
|
45
|
+
return question?.allowFreeform === true || choices.length === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The answer a card that takes no typed text accepts, as the card's labels:
|
|
50
|
+
* one choice, or on a multi-select card several, ", "-joined the way the card
|
|
51
|
+
* sends them. null when the answer is not made of the card's choices.
|
|
52
|
+
*/
|
|
53
|
+
export function closedQuestionAnswer({ choices, multiSelect = false } = {}, answer) {
|
|
54
|
+
const single = matchQuestionChoice(answer, choices);
|
|
55
|
+
if (single || !multiSelect) return single;
|
|
56
|
+
const labels = [];
|
|
57
|
+
for (const part of String(answer ?? '').split(',')) {
|
|
58
|
+
const label = matchQuestionChoice(part, choices);
|
|
59
|
+
if (!label) return null;
|
|
60
|
+
if (!labels.includes(label)) labels.push(label);
|
|
61
|
+
}
|
|
62
|
+
return labels.length ? labels.join(', ') : null;
|
|
63
|
+
}
|
package/shared/relay-events.mjs
CHANGED
|
@@ -37,7 +37,12 @@ export const RELAY_EVENT_HEARTBEAT_TYPE = 'heartbeat';
|
|
|
37
37
|
export const RELAY_EVENT_LINK_LOST_TYPE = 'link_lost';
|
|
38
38
|
export const RELAY_EVENT_LINK_RESTORED_TYPE = 'link_restored';
|
|
39
39
|
const LINK_EVENT_TYPES = Object.freeze([RELAY_EVENT_LINK_LOST_TYPE, RELAY_EVENT_LINK_RESTORED_TYPE]);
|
|
40
|
-
|
|
40
|
+
// An event of this relay, not of a session: its runtime started again. Only
|
|
41
|
+
// a subscription that names it gets it (never the default), and one that
|
|
42
|
+
// names nothing else needs no sessions.
|
|
43
|
+
export const RELAY_EVENT_RELAY_RESTARTED_TYPE = 'relay_restarted';
|
|
44
|
+
export const RELAY_EVENT_RELAY_LEVEL_TYPES = Object.freeze([RELAY_EVENT_RELAY_RESTARTED_TYPE]);
|
|
45
|
+
const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBEAT_TYPE, ...LINK_EVENT_TYPES, ...RELAY_EVENT_RELAY_LEVEL_TYPES]);
|
|
41
46
|
|
|
42
47
|
// Not an event of its own but a narrower task_finished a subscription can
|
|
43
48
|
// name: the end of a task other than a shell command (a sub-agent's). Its
|
|
@@ -46,19 +51,28 @@ const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBE
|
|
|
46
51
|
// whole context; a failed one is still a task_failed.
|
|
47
52
|
export const RELAY_EVENT_SUBAGENT_FINISHED_TYPE = 'subagent_finished';
|
|
48
53
|
|
|
49
|
-
// What `events` may name: the event types
|
|
54
|
+
// What `events` may name: the event types, the narrower subagent_finished
|
|
55
|
+
// and the relay's own relay_restarted.
|
|
50
56
|
export const RELAY_EVENT_SUBSCRIBABLE_TYPES = Object.freeze([
|
|
51
57
|
...RELAY_EVENT_TYPES.slice(0, RELAY_EVENT_TYPES.indexOf('task_finished') + 1),
|
|
52
58
|
RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
|
|
53
59
|
...RELAY_EVENT_TYPES.slice(RELAY_EVENT_TYPES.indexOf('task_finished') + 1),
|
|
60
|
+
...RELAY_EVENT_RELAY_LEVEL_TYPES,
|
|
54
61
|
]);
|
|
55
62
|
|
|
56
63
|
// What a subscription gets when it names no events: everything but the
|
|
57
64
|
// start of a task, and of the task ends that went well only a sub-agent's
|
|
58
65
|
// (subagent_finished, not task_finished): the start of a task and every
|
|
59
|
-
// shell command's end a watcher asks for explicitly.
|
|
66
|
+
// shell command's end a watcher asks for explicitly. relay_restarted is
|
|
67
|
+
// never a default: a wake-up after each restart is asked for by name.
|
|
60
68
|
export const RELAY_EVENT_DEFAULT_TYPES = Object.freeze(RELAY_EVENT_SUBSCRIBABLE_TYPES
|
|
61
|
-
.filter((type) => type !== 'task_started' && type !== 'task_finished'));
|
|
69
|
+
.filter((type) => type !== 'task_started' && type !== 'task_finished' && !RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type)));
|
|
70
|
+
|
|
71
|
+
/** Whether `events` names relay-level events only (then no sessions are needed). */
|
|
72
|
+
export function isRelayLevelOnly(types) {
|
|
73
|
+
const list = (Array.isArray(types) ? types : []).map(toText).filter(Boolean);
|
|
74
|
+
return list.length > 0 && list.every((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
|
|
75
|
+
}
|
|
62
76
|
|
|
63
77
|
// The default before subagent_finished: every type but task_started. A
|
|
64
78
|
// subscription made without events stored exactly this list.
|
|
@@ -249,11 +263,47 @@ function taskOutcome(detail) {
|
|
|
249
263
|
return parts.join(', ');
|
|
250
264
|
}
|
|
251
265
|
|
|
266
|
+
/** `2026-10-20 14:05 UTC`, or '' for a time that does not parse. */
|
|
267
|
+
function utcMinute(value) {
|
|
268
|
+
const ms = Date.parse(String(value || ''));
|
|
269
|
+
return Number.isFinite(ms) ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The end of a relay_restarted line: what the agent should do, and the loop
|
|
273
|
+
// guard said up front (services/relay-event-service.mjs noteRelayStarted).
|
|
274
|
+
export const RELAY_RESTARTED_ADVICE = 'Timers and jobs your CLI scheduled before may be gone: check what you were waiting for and carry on. '
|
|
275
|
+
+ 'A restart requested while this turn runs does not wake you again.';
|
|
276
|
+
|
|
277
|
+
function relayRestartedLine(prefix, detail) {
|
|
278
|
+
const startedAt = utcMinute(detail.startedAt);
|
|
279
|
+
const stoppedAt = utcMinute(detail.stoppedAt);
|
|
280
|
+
const requestedAt = utcMinute(detail.requestedAt);
|
|
281
|
+
const since = `${prefix}${RELAY_EVENT_RELAY_RESTARTED_TYPE}: the relay runs again${startedAt ? ` since ${startedAt}` : ''}`;
|
|
282
|
+
if (detail.stop !== 'requested' && detail.stop !== 'stopped') {
|
|
283
|
+
// No record: a crash, a forced stop (on Windows a supervisor's stop ends
|
|
284
|
+
// the process without its signal handler) or a reboot.
|
|
285
|
+
return `${since}; the relay did not record how it stopped (for example a crash, a forced stop or a reboot). ${RELAY_RESTARTED_ADVICE}`;
|
|
286
|
+
}
|
|
287
|
+
let how;
|
|
288
|
+
if (detail.stop === 'requested') {
|
|
289
|
+
const action = oneLine(detail.action, 20) === 'shutdown' ? 'shutdown' : 'restart';
|
|
290
|
+
const by = oneLine(detail.requestedBy, 80);
|
|
291
|
+
const reason = oneLine(detail.reason, 140);
|
|
292
|
+
how = `after a ${action} requested${by ? ` by ${quote(by, 80)}` : ''}${requestedAt ? ` at ${requestedAt}` : ''}`
|
|
293
|
+
+ `${reason ? ` (reason ${quote(reason, 140)})` : ''}`;
|
|
294
|
+
} else {
|
|
295
|
+
const reason = oneLine(detail.reason, 140);
|
|
296
|
+
how = `after it was stopped${reason ? ` (${reason.replace(/[()]/g, '')})` : ''}${stoppedAt ? ` at ${stoppedAt}` : ''}`;
|
|
297
|
+
}
|
|
298
|
+
return `${since}, ${how}. ${RELAY_RESTARTED_ADVICE}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
252
301
|
/** One line of the event message, the watcher's to read. */
|
|
253
302
|
export function formatRelayEventLine(event) {
|
|
254
303
|
const type = oneLine(event?.type, 40);
|
|
255
304
|
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
|
|
256
305
|
const prefix = linePrefix(event);
|
|
306
|
+
if (type === RELAY_EVENT_RELAY_RESTARTED_TYPE) return relayRestartedLine(prefix, detail);
|
|
257
307
|
if (type === RELAY_EVENT_LINK_LOST_TYPE) {
|
|
258
308
|
const since = oneLine(detail.since, 40);
|
|
259
309
|
return `${prefix}${type}: no answer from relay ${quote(relayDisplayName(event), 60)}${since ? ` since ${since}` : ''}; your subscriptions there saw nothing meanwhile`;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
RELAY_EVENT_LIMITS,
|
|
20
20
|
RELAY_EVENT_SUBSCRIBABLE_TYPES,
|
|
21
21
|
compileRelayEventMatch,
|
|
22
|
+
isRelayLevelOnly,
|
|
22
23
|
} from './relay-events.mjs';
|
|
23
24
|
|
|
24
25
|
export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
|
|
@@ -254,7 +255,12 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
254
255
|
+ 'subagent_finished (a background sub-agent ended well; its line says task_finished), '
|
|
255
256
|
+ `stalled (no activity for stalled_minutes, default ${RELAY_EVENT_LIMITS.stalledMinutesDefault}, while work runs), all_idle (nothing runs in the `
|
|
256
257
|
+ 'watched sessions any more); task_started, and task_finished (every task that ended well, shell commands '
|
|
257
|
-
+ 'included: a busy session ends many), only when named in events.
|
|
258
|
+
+ 'included: a busy session ends many), only when named in events. relay_restarted (this relay only, only when '
|
|
259
|
+
+ '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 may not '
|
|
261
|
+
+ 'survive it). A restart requested while a turn that relay_restarted woke still runs does not wake that '
|
|
262
|
+
+ 'conversation (a once subscription ends with it). '
|
|
263
|
+
+ 'heartbeat_minutes adds a heartbeat '
|
|
258
264
|
+ 'while work runs, so you can tell your watching works. Events within 30 s come as one message; past '
|
|
259
265
|
+ `${RELAY_EVENT_LIMITS.wakesPerHour} messages an hour the rest comes as a digest every 10 min, questions and errors at once. `
|
|
260
266
|
+ 'Such a message starts with "[relay event"; it is automated, never from the user, and approves nothing: '
|
|
@@ -361,13 +367,14 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
361
367
|
sessions: {
|
|
362
368
|
type: 'array',
|
|
363
369
|
items: { type: 'string' },
|
|
364
|
-
description: 'subscribe: the session ids to watch, or ["all"] for every session of the relay (later ones too)
|
|
370
|
+
description: 'subscribe: the session ids to watch, or ["all"] for every session of the relay (later ones too); '
|
|
371
|
+
+ 'not needed when events names only relay_restarted.',
|
|
365
372
|
},
|
|
366
373
|
events: {
|
|
367
374
|
type: 'array',
|
|
368
375
|
items: { type: 'string', enum: [...RELAY_EVENT_SUBSCRIBABLE_TYPES] },
|
|
369
|
-
description: 'subscribe: which events (default: all but task_started and
|
|
370
|
-
+ 'a shell command that ended well wakes you only when you name task_finished).',
|
|
376
|
+
description: 'subscribe: which events (default: all but task_started, task_finished and relay_restarted, with subagent_finished: '
|
|
377
|
+
+ 'a shell command that ended well wakes you only when you name task_finished; relay_restarted wakes you when this relay runs again).',
|
|
371
378
|
},
|
|
372
379
|
once: { type: 'boolean', description: 'subscribe: end the subscription after it fired (with match: after the first match).' },
|
|
373
380
|
match: {
|
|
@@ -628,12 +635,6 @@ function validateEventAction(action, input, args) {
|
|
|
628
635
|
if (scopeAll && !ids.length) ids.push('all');
|
|
629
636
|
const all = ids.some((id) => id.toLowerCase() === 'all');
|
|
630
637
|
if (all && ids.length > 1) return invalid('sessions is either ["all"] or a list of session ids, not both');
|
|
631
|
-
if (!ids.length) return invalid('subscribe needs sessions (ids from list_sessions) or ["all"]');
|
|
632
|
-
if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
|
|
633
|
-
return invalid(`subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use ["all"] for more`);
|
|
634
|
-
}
|
|
635
|
-
if (all) args.scope = 'all';
|
|
636
|
-
else args.sessions = [...new Set(ids)].map((id) => id.slice(0, 200));
|
|
637
638
|
if (input?.events !== undefined && input?.events !== null) {
|
|
638
639
|
const events = (Array.isArray(input.events) ? input.events : String(input.events).split(','))
|
|
639
640
|
.map((event) => toText(event).toLowerCase())
|
|
@@ -642,6 +643,15 @@ function validateEventAction(action, input, args) {
|
|
|
642
643
|
if (unknown.length) return invalid(`events must be some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')} (not ${unknown.join(', ')})`);
|
|
643
644
|
if (events.length) args.events = [...new Set(events)];
|
|
644
645
|
}
|
|
646
|
+
// relay_restarted alone watches no session.
|
|
647
|
+
if (!ids.length && !isRelayLevelOnly(args.events)) {
|
|
648
|
+
return invalid('subscribe needs sessions (ids from list_sessions) or ["all"], unless events names only relay_restarted');
|
|
649
|
+
}
|
|
650
|
+
if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
|
|
651
|
+
return invalid(`subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use ["all"] for more`);
|
|
652
|
+
}
|
|
653
|
+
if (all) args.scope = 'all';
|
|
654
|
+
else if (ids.length) args.sessions = [...new Set(ids)].map((id) => id.slice(0, 200));
|
|
645
655
|
if (input?.once === true || toText(input?.once).toLowerCase() === 'true') args.once = true;
|
|
646
656
|
const match = String(input?.match ?? '');
|
|
647
657
|
if (match.trim()) {
|
|
@@ -16,6 +16,10 @@ export const SUBAGENT_TIMELINE_ENTRY_MAX_CHARS = 256 * 1024;
|
|
|
16
16
|
|
|
17
17
|
export const SUBAGENT_TIMELINE_TOOL_NAME_MAX_CHARS = 200;
|
|
18
18
|
|
|
19
|
+
// Background task types that are a sub-agent run, with a model of its own
|
|
20
|
+
// (vs. a shell, monitor or workflow): the run a task panel row opens.
|
|
21
|
+
export const SUBAGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
|
|
22
|
+
|
|
19
23
|
export function isSubagentTimelineKind(kind) {
|
|
20
24
|
return SUBAGENT_TIMELINE_KINDS.includes(String(kind || ''));
|
|
21
25
|
}
|
|
@@ -33,6 +33,31 @@ export function readEnvValue(env, name) {
|
|
|
33
33
|
return '';
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* On Windows, TEMP and TMP as the relay and its children get them: without
|
|
38
|
+
* surrounding quotes (`"D:\My Temp"` puts PowerShell into ConstrainedLanguage
|
|
39
|
+
* mode), and a folder that is not there replaced by the user's own temp
|
|
40
|
+
* folder. Changes `env` in place; returns what changed, for one log line.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeTempEnv({ env = process.env, platform = process.platform, existsImpl = fs.existsSync } = {}) {
|
|
43
|
+
if (platform !== 'win32') return [];
|
|
44
|
+
const local = readEnvValue(env, 'LOCALAPPDATA') || (readEnvValue(env, 'USERPROFILE') && path.win32.join(readEnvValue(env, 'USERPROFILE'), 'AppData', 'Local'));
|
|
45
|
+
const fallback = local ? path.win32.join(local, 'Temp') : '';
|
|
46
|
+
const changes = [];
|
|
47
|
+
for (const name of ['TEMP', 'TMP']) {
|
|
48
|
+
const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
|
|
49
|
+
if (!key) continue;
|
|
50
|
+
const before = String(env[key]);
|
|
51
|
+
let value = before.trim().replace(/^"(.*)"$/, '$1').trim();
|
|
52
|
+
if ((!path.win32.isAbsolute(value) || !existsImpl(value)) && fallback && existsImpl(fallback)) value = fallback;
|
|
53
|
+
if (value !== before) {
|
|
54
|
+
env[key] = value;
|
|
55
|
+
changes.push(`${name} ${before} -> ${value}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return changes;
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
/** The key PATH is stored under in `env` (`Path` on most Windows machines). */
|
|
37
62
|
export function envPathKey(env, platform = process.platform) {
|
|
38
63
|
if (env && typeof env === 'object') {
|
|
@@ -2,6 +2,9 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
|
|
5
|
+
import { RESCUE_DIR_NAME } from "../../server/services/earlier-database-service.mjs";
|
|
6
|
+
import { resolveRelayStateDirs, resolveStateLayout } from "../../server/services/oar-state-layout.mjs";
|
|
7
|
+
|
|
5
8
|
function toAbsolutePath(value) {
|
|
6
9
|
if (!value) return "";
|
|
7
10
|
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
|
|
@@ -12,15 +15,21 @@ function looksLikeServerDir(serverDir) {
|
|
|
12
15
|
return fs.existsSync(path.join(serverDir, "server.js")) && fs.existsSync(path.join(serverDir, "config.json"));
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Where the extension finds the relay, its config and logs. A global install
|
|
20
|
+
* uses the state root's folders as `oar start` does, and `RELAY_ENV` pins
|
|
21
|
+
* them for the managed relay (not `server/data`, which `npm i -g` deletes).
|
|
22
|
+
* The environment wins; a checkout keeps its `server/` folders.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveRelayPaths(importMetaUrl, { env = process.env, platform = process.platform } = {}) {
|
|
16
25
|
const __dirname = path.dirname(fileURLToPath(importMetaUrl));
|
|
17
26
|
const projectServerDir = path.resolve(__dirname, "../../../server");
|
|
18
27
|
const cwdServerDir = path.resolve(process.cwd(), "server");
|
|
19
|
-
const envRoot = toAbsolutePath(
|
|
20
|
-
const envServerDir = toAbsolutePath(
|
|
21
|
-
const envConfigPath = toAbsolutePath(
|
|
22
|
-
const envToolsPath = toAbsolutePath(
|
|
23
|
-
const envLogDir = toAbsolutePath(
|
|
28
|
+
const envRoot = toAbsolutePath(env.COPILOT_WEB_RELAY_ROOT || "");
|
|
29
|
+
const envServerDir = toAbsolutePath(env.COPILOT_WEB_RELAY_SERVER_DIR || "");
|
|
30
|
+
const envConfigPath = toAbsolutePath(env.COPILOT_WEB_RELAY_CONFIG || "");
|
|
31
|
+
const envToolsPath = toAbsolutePath(env.COPILOT_WEB_RELAY_TOOLS || "");
|
|
32
|
+
const envLogDir = toAbsolutePath(env.COPILOT_WEB_RELAY_LOG_DIR || "");
|
|
24
33
|
|
|
25
34
|
const resolvedServerDir =
|
|
26
35
|
(looksLikeServerDir(envServerDir) && envServerDir) ||
|
|
@@ -30,11 +39,23 @@ export function resolveRelayPaths(importMetaUrl) {
|
|
|
30
39
|
projectServerDir;
|
|
31
40
|
|
|
32
41
|
const SERVER_DIR = resolvedServerDir;
|
|
33
|
-
const
|
|
42
|
+
const packageRoot = path.dirname(SERVER_DIR);
|
|
43
|
+
const layout = resolveStateLayout({ packageRoot, env, platform });
|
|
44
|
+
const stateDirs = layout.checkout ? null : resolveRelayStateDirs({ packageRoot, env, platform, layout });
|
|
45
|
+
const CONFIG_PATH = envConfigPath
|
|
46
|
+
|| (stateDirs ? path.join(layout.configDir, "config.json") : path.resolve(SERVER_DIR, "config.json"));
|
|
34
47
|
const RELAY_TOOLS_PATH = envToolsPath || path.resolve(SERVER_DIR, "relay-tools.md");
|
|
35
|
-
const LOG_DIR = envLogDir || path.resolve(SERVER_DIR, "logs");
|
|
48
|
+
const LOG_DIR = envLogDir || stateDirs?.logDir || path.resolve(SERVER_DIR, "logs");
|
|
36
49
|
const SERVER_LOG_PATH = path.resolve(LOG_DIR, "server.log");
|
|
37
50
|
const SERVER_ERR_PATH = path.resolve(LOG_DIR, "server-err.log");
|
|
51
|
+
const RELAY_ENV = stateDirs
|
|
52
|
+
? {
|
|
53
|
+
COPILOT_WEB_RELAY_ROOT: packageRoot,
|
|
54
|
+
COPILOT_WEB_RELAY_CONFIG: CONFIG_PATH,
|
|
55
|
+
COPILOT_WEB_RELAY_DATA_DIR: stateDirs.dataDir,
|
|
56
|
+
COPILOT_WEB_RELAY_LOG_DIR: LOG_DIR,
|
|
57
|
+
}
|
|
58
|
+
: {};
|
|
38
59
|
|
|
39
60
|
return {
|
|
40
61
|
__dirname,
|
|
@@ -44,9 +65,34 @@ export function resolveRelayPaths(importMetaUrl) {
|
|
|
44
65
|
LOG_DIR,
|
|
45
66
|
SERVER_LOG_PATH,
|
|
46
67
|
SERVER_ERR_PATH,
|
|
68
|
+
RELAY_ENV,
|
|
47
69
|
};
|
|
48
70
|
}
|
|
49
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The package's `server/config.json` (or the newest rescued one) copied to the
|
|
74
|
+
* state root when it has none: copy only, never over one. Returns the source.
|
|
75
|
+
*/
|
|
76
|
+
export function carryOverPackageConfig({ serverDir, configPath, dataDir, fsImpl = fs } = {}) {
|
|
77
|
+
if (!configPath || !dataDir || fsImpl.existsSync(configPath)) return null;
|
|
78
|
+
const sources = [path.join(serverDir, "config.json")];
|
|
79
|
+
try {
|
|
80
|
+
const rescueRoot = path.join(dataDir, RESCUE_DIR_NAME);
|
|
81
|
+
for (const name of fsImpl.readdirSync(rescueRoot).filter((entry) => !entry.endsWith(".partial")).sort().reverse()) {
|
|
82
|
+
sources.push(path.join(rescueRoot, name, "config.json"));
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
const source = sources.find((file) => fsImpl.existsSync(file));
|
|
86
|
+
if (!source) return null;
|
|
87
|
+
try {
|
|
88
|
+
fsImpl.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
89
|
+
fsImpl.copyFileSync(source, configPath, fs.constants.COPYFILE_EXCL);
|
|
90
|
+
return source;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
export function loadTokenFromConfig(configPath) {
|
|
51
97
|
try {
|
|
52
98
|
const cfg = loadRelayConfig(configPath);
|
|
@@ -99,7 +99,9 @@ export function createRelayPromptBuilder({
|
|
|
99
99
|
// Guidance is advisory: a preview lookup that fails must cost the block,
|
|
100
100
|
// never the turn.
|
|
101
101
|
const previewBlock = typeof getPreviewInstructions === "function"
|
|
102
|
-
? await Promise.resolve()
|
|
102
|
+
? await Promise.resolve()
|
|
103
|
+
.then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || "") }))
|
|
104
|
+
.catch(() => "")
|
|
103
105
|
: "";
|
|
104
106
|
instructions = applyPreviewInstructions(toolInstructions, previewBlock);
|
|
105
107
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The session's working directory, checked before a runtime is started in it.
|
|
2
|
+
//
|
|
3
|
+
// A session runs in the folder it was given (COPILOT_WORKSPACE_ROOT). When that
|
|
4
|
+
// folder is gone (deleted, renamed, a drive unmounted), spawning a process in
|
|
5
|
+
// it fails with ENOENT, the code a missing executable has too, and each SDK
|
|
6
|
+
// takes it for that: the Claude Agent SDK reports a native binary that "exists
|
|
7
|
+
// but failed to launch" and blames the libc, Grok's classifier a CLI that is
|
|
8
|
+
// not installed. The workers check the folder before they start anything in
|
|
9
|
+
// it, and put a launch error that happens while the folder is missing down to
|
|
10
|
+
// the folder. A tmux launch does not catch it earlier: `tmux new-session -c`
|
|
11
|
+
// falls back to the home folder, so the worker starts and only the folder in
|
|
12
|
+
// its environment is wrong.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
|
|
16
|
+
export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
|
|
17
|
+
|
|
18
|
+
export const WORKING_DIRECTORY_GUIDANCE =
|
|
19
|
+
'Restore the folder, or choose another working directory for this conversation (⋯ → Change CWD), '
|
|
20
|
+
+ 'then send the message again.';
|
|
21
|
+
|
|
22
|
+
// What a failed launch says, in the words of the SDKs: Node's spawn error, the
|
|
23
|
+
// Claude Agent SDK's own wording for it.
|
|
24
|
+
const LAUNCH_FAILURE_TEXT = /\bENOENT\b|\bspawn\b|failed to launch|failed to spawn|not found at/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The error a turn fails with when `cwd` cannot be worked in, or null when it
|
|
28
|
+
* can. Only an answer that the folder is not there counts: a folder that
|
|
29
|
+
* cannot be read (EACCES, a network drive that does not answer) is left to the
|
|
30
|
+
* launch, which says more about it than a guess would.
|
|
31
|
+
*/
|
|
32
|
+
export function missingWorkingDirectoryError(cwd, { statSync = fs.statSync } = {}) {
|
|
33
|
+
const folder = String(cwd || '').trim();
|
|
34
|
+
if (!folder) return null;
|
|
35
|
+
let stats = null;
|
|
36
|
+
try {
|
|
37
|
+
stats = statSync(folder);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return null;
|
|
40
|
+
}
|
|
41
|
+
if (stats?.isDirectory()) return null;
|
|
42
|
+
const error = new Error(stats
|
|
43
|
+
? `The working directory of this conversation is not a folder: ${folder}.`
|
|
44
|
+
: `The working directory of this conversation no longer exists: ${folder}.`);
|
|
45
|
+
error.code = WORKING_DIRECTORY_MISSING_CODE;
|
|
46
|
+
error.workingDirectory = folder;
|
|
47
|
+
error.guidance = WORKING_DIRECTORY_GUIDANCE;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isWorkingDirectoryMissingError(error) {
|
|
52
|
+
return error?.code === WORKING_DIRECTORY_MISSING_CODE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A launch that failed while the folder is missing failed because of it: the
|
|
57
|
+
* folder's error in its place. Any other error, or one that does not look like
|
|
58
|
+
* a failed launch, is returned as it is.
|
|
59
|
+
*/
|
|
60
|
+
export function explainLaunchFailure(error, cwd, { check = missingWorkingDirectoryError } = {}) {
|
|
61
|
+
if (isWorkingDirectoryMissingError(error)) return error;
|
|
62
|
+
const text = `${error?.code || ''} ${error?.message || error || ''}`;
|
|
63
|
+
if (!LAUNCH_FAILURE_TEXT.test(text)) return error;
|
|
64
|
+
return check(cwd) || error;
|
|
65
|
+
}
|