@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.
Files changed (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. 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 answer is
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
- let cachedText = '';
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 text = response?.enabled === true
33
- ? renderPreviewInstructionBlock({ publicBaseUrl: response?.publicBaseUrl || '' })
34
- : '';
35
- cachedText = text;
35
+ const lane = response?.enabled === true ? { publicBaseUrl: response?.publicBaseUrl || '' } : null;
36
+ cachedLane = lane;
36
37
  cachedUntil = now() + ttlMs;
37
- return text;
38
+ return lane;
38
39
  }
39
40
 
40
- return async function getPreviewInstructions() {
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 cachedText;
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 existing previews. Use '
19
- + 'this whenever the user wants to see, try, or share something with a web UI '
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', '/api/previews');
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?conversationId=${encodeURIComponent(conversationId)}`);
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
- const response = await api('DELETE', `/api/previews/${token}`);
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
- return { ok: false, error: String(error?.message || error) };
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": "<conv>", "port": 5173, "label": "web app" }\` → \`{ "url": "${base}/test_<token>/", "basePath": "/test_<token>/" }\`. Register FIRST, then start the server under the returned basePath.
197
- - 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.
198
- - List: \`GET /api/previews\` (\`?conversationId=\` filters). Close: \`DELETE /api/previews/:token\` — closes the link only, never the dev server behind it.
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
+ }
@@ -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
- const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBEAT_TYPE, ...LINK_EVENT_TYPES]);
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 and the narrower subagent_finished.
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. heartbeat_minutes adds a heartbeat '
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 task_finished, with subagent_finished: '
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
- export function resolveRelayPaths(importMetaUrl) {
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(process.env.COPILOT_WEB_RELAY_ROOT || "");
20
- const envServerDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_SERVER_DIR || "");
21
- const envConfigPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_CONFIG || "");
22
- const envToolsPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_TOOLS || "");
23
- const envLogDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_LOG_DIR || "");
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 CONFIG_PATH = envConfigPath || path.resolve(SERVER_DIR, "config.json");
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().then(getPreviewInstructions).catch(() => "")
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
+ }