@oar-sh/oar 0.9.10 → 0.9.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -3,6 +3,11 @@ import path from 'path';
|
|
|
3
3
|
import { execFile, spawn } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
6
|
+
import {
|
|
7
|
+
describeExecError,
|
|
8
|
+
describeSpawnEnvironment,
|
|
9
|
+
windowsSystemToolPath,
|
|
10
|
+
} from '../../shared/windows-system-tools.mjs';
|
|
6
11
|
import {
|
|
7
12
|
LEGACY_WINDOWS_AUTOSTART_FILENAME,
|
|
8
13
|
WINDOWS_AUTOSTART_FILENAME,
|
|
@@ -40,7 +45,13 @@ export const LEGACY_BOOT_TASK_NAME = 'copilot-remote-relay';
|
|
|
40
45
|
export const BOOT_LAUNCHER_FILENAME = 'oar-web-relay-launcher.cmd';
|
|
41
46
|
export const BOOT_TASK_XML_FILENAME = 'oar-relay-task.xml';
|
|
42
47
|
export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
|
|
48
|
+
// Where a regenerated launcher keeps the one it replaced, when they differ.
|
|
49
|
+
export const BOOT_LAUNCHER_BACKUP_SUFFIX = '.bak';
|
|
43
50
|
const ELEVATION_TIMEOUT_MS = 120_000;
|
|
51
|
+
export const BOOT_TEMP_WAIT_SECONDS = 120;
|
|
52
|
+
export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
|
|
53
|
+
// Each schtasks query; the settings page waits on two of them.
|
|
54
|
+
const QUERY_TIMEOUT_MS = 6_000;
|
|
44
55
|
|
|
45
56
|
function normalizeText(value) {
|
|
46
57
|
return String(value == null ? '' : value).trim();
|
|
@@ -58,6 +69,19 @@ function escapeXml(value) {
|
|
|
58
69
|
/**
|
|
59
70
|
* The launcher the task executes. Same shape as the sign-in .cmd minus the
|
|
60
71
|
* `title` line: a boot task runs in session 0 where no console is ever shown.
|
|
72
|
+
*
|
|
73
|
+
* Node's folder goes at the end of PATH. The relay itself runs on the
|
|
74
|
+
* absolute path below, but a boot task's PATH can lack the user's Node (a
|
|
75
|
+
* version manager's folder, an entry that refers to an unexpanded variable),
|
|
76
|
+
* and the CLIs and commands the workers start may call a bare `node`. At the
|
|
77
|
+
* end it only matters when PATH has no Node of its own.
|
|
78
|
+
*
|
|
79
|
+
* Before Node starts, the launcher waits up to BOOT_TEMP_WAIT_SECONDS for the
|
|
80
|
+
* %TEMP% folder: at boot it can live on a drive that a startup script creates
|
|
81
|
+
* only after the task has fired, and Node, the workers and their CLIs then
|
|
82
|
+
* fail creating their temp files. It costs nothing when the folder is there,
|
|
83
|
+
* is skipped when TEMP is not set, and a wait that runs out is logged to
|
|
84
|
+
* BOOT_LAUNCHER_LOG_FILENAME next to the launcher before Node starts anyway.
|
|
61
85
|
*/
|
|
62
86
|
export function buildBootLauncherScript({
|
|
63
87
|
packageRoot,
|
|
@@ -78,10 +102,12 @@ export function buildBootLauncherScript({
|
|
|
78
102
|
}
|
|
79
103
|
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
80
104
|
const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
|
|
105
|
+
const resolvedNodePath = pathImpl.resolve(nodePath);
|
|
81
106
|
const lines = [
|
|
82
107
|
'@echo off',
|
|
83
108
|
'rem Generated by OAR settings (system-startup autostart). Regenerated on',
|
|
84
109
|
'rem every settings change - do not edit by hand.',
|
|
110
|
+
`set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
|
|
85
111
|
];
|
|
86
112
|
if (normalizeText(configPath)) {
|
|
87
113
|
lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
|
|
@@ -90,13 +116,36 @@ export function buildBootLauncherScript({
|
|
|
90
116
|
? pathImpl.resolve(String(workingDirectory))
|
|
91
117
|
: resolvedPackageRoot;
|
|
92
118
|
lines.push(
|
|
119
|
+
// About one second a round: ping waits a second between its two echoes.
|
|
120
|
+
// By full path, since the PATH of an early boot may not lead to System32.
|
|
121
|
+
'if "%TEMP%"=="" goto oar_start',
|
|
122
|
+
'set /a OAR_TEMP_WAIT=0',
|
|
123
|
+
':oar_wait_temp',
|
|
124
|
+
'if exist "%TEMP%\\" goto oar_start',
|
|
125
|
+
`if %OAR_TEMP_WAIT% geq ${BOOT_TEMP_WAIT_SECONDS} goto oar_temp_missing`,
|
|
126
|
+
'"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
|
|
127
|
+
'set /a OAR_TEMP_WAIT+=1',
|
|
128
|
+
'goto oar_wait_temp',
|
|
129
|
+
':oar_temp_missing',
|
|
130
|
+
`echo [%DATE% %TIME%] "%TEMP%" still missing after ${BOOT_TEMP_WAIT_SECONDS} s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
|
|
131
|
+
':oar_start',
|
|
132
|
+
'set "OAR_TEMP_WAIT="',
|
|
93
133
|
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
94
|
-
`"${escapeBatchValue(
|
|
134
|
+
`"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
|
|
95
135
|
'',
|
|
96
136
|
);
|
|
97
137
|
return lines.join('\r\n');
|
|
98
138
|
}
|
|
99
139
|
|
|
140
|
+
function unescapeXml(value) {
|
|
141
|
+
return String(value)
|
|
142
|
+
.replaceAll('"', '"')
|
|
143
|
+
.replaceAll(''', "'")
|
|
144
|
+
.replaceAll('<', '<')
|
|
145
|
+
.replaceAll('>', '>')
|
|
146
|
+
.replaceAll('&', '&');
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
/**
|
|
101
150
|
* Task Scheduler XML, settings ported verbatim from the proven manual task.
|
|
102
151
|
* The S4U principal is registered by SID, not account name: a by-name
|
|
@@ -176,17 +225,64 @@ export function parseTaskXml(xmlText) {
|
|
|
176
225
|
const text = String(xmlText == null ? '' : xmlText);
|
|
177
226
|
const grab = (tag) => {
|
|
178
227
|
const match = text.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
|
|
179
|
-
return match ? match[1].trim() : null;
|
|
228
|
+
return match ? unescapeXml(match[1]).trim() : null;
|
|
180
229
|
};
|
|
230
|
+
const bootTrigger = text.match(/<BootTrigger>([\s\S]*?)<\/BootTrigger>/);
|
|
231
|
+
const bootDelay = bootTrigger ? (bootTrigger[1].match(/<Delay>([^<]*)<\/Delay>/)?.[1]?.trim() || null) : null;
|
|
181
232
|
return {
|
|
182
233
|
command: grab('Command'),
|
|
183
234
|
argumentsLine: grab('Arguments'),
|
|
184
235
|
workingDirectory: grab('WorkingDirectory'),
|
|
185
236
|
logonType: grab('LogonType'),
|
|
186
237
|
userId: grab('UserId'),
|
|
238
|
+
hasBootTrigger: Boolean(bootTrigger),
|
|
239
|
+
bootDelay,
|
|
187
240
|
};
|
|
188
241
|
}
|
|
189
242
|
|
|
243
|
+
function sameText(a, b) {
|
|
244
|
+
return normalizeText(a).toLowerCase() === normalizeText(b).toLowerCase();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Windows paths: no case, either slash, no trailing separator.
|
|
248
|
+
function samePath(a, b) {
|
|
249
|
+
const normalize = (value) => normalizeText(value).replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase();
|
|
250
|
+
return normalize(a) === normalize(b);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* How a task that runs our launcher differs from the one buildBootTaskXml
|
|
255
|
+
* registers, as short names (empty when it does not). Only what the generated
|
|
256
|
+
* XML sets is compared: Task Scheduler adds its own defaults on export, and
|
|
257
|
+
* the run level, for one, is not set by OAR.
|
|
258
|
+
*/
|
|
259
|
+
export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory } = {}) {
|
|
260
|
+
const parsed = parseTaskXml(xmlText);
|
|
261
|
+
const differences = [];
|
|
262
|
+
if (!sameText(parsed.command, 'C:\\Windows\\System32\\cmd.exe')
|
|
263
|
+
|| normalizeText(parsed.argumentsLine).replace(/\s+/g, ' ').toLowerCase()
|
|
264
|
+
!== `/d /c "${normalizeText(launcherPath)}"`.toLowerCase()) {
|
|
265
|
+
differences.push('action');
|
|
266
|
+
}
|
|
267
|
+
if (normalizeText(workingDirectory) && !samePath(parsed.workingDirectory, workingDirectory)) {
|
|
268
|
+
differences.push('working-directory');
|
|
269
|
+
}
|
|
270
|
+
if (!sameText(parsed.logonType, 'S4U')) differences.push('logon-type');
|
|
271
|
+
if (!parsed.hasBootTrigger) differences.push('trigger');
|
|
272
|
+
else if (!sameText(parsed.bootDelay, 'PT30S')) differences.push('boot-delay');
|
|
273
|
+
return differences;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Same launcher, whatever the line endings and trailing blanks. */
|
|
277
|
+
export function launcherScriptsMatch(actual, expected) {
|
|
278
|
+
const normalize = (value) => String(value == null ? '' : value)
|
|
279
|
+
.split(/\r?\n/)
|
|
280
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
281
|
+
.join('\n')
|
|
282
|
+
.trim();
|
|
283
|
+
return normalize(actual) === normalize(expected);
|
|
284
|
+
}
|
|
285
|
+
|
|
190
286
|
/**
|
|
191
287
|
* Ours or someone else's? A task counts as ours when its action runs our
|
|
192
288
|
* launcher path; anything else under our name is `foreign` and the UI offers
|
|
@@ -206,23 +302,25 @@ export function classifyTaskXml(xmlText, { launcherPath } = {}) {
|
|
|
206
302
|
* and nothing else — in one script, so one UAC covers the whole transition
|
|
207
303
|
* out of any mixed state (legacy-named task, hand-made task, fresh install).
|
|
208
304
|
*/
|
|
209
|
-
export function buildElevationScript({ action, xmlPath } = {}) {
|
|
305
|
+
export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks' } = {}) {
|
|
306
|
+
// PowerShell runs a quoted path only behind the call operator.
|
|
307
|
+
const schtasks = /[\\/]/.test(schtasksPath) ? `& "${schtasksPath}"` : schtasksPath;
|
|
210
308
|
if (action === 'enable') {
|
|
211
309
|
if (!normalizeText(xmlPath)) {
|
|
212
310
|
throw new Error('The enable sweep needs the task XML path.');
|
|
213
311
|
}
|
|
214
312
|
return [
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
313
|
+
`${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
|
|
314
|
+
`${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
|
|
315
|
+
`${schtasks} /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
|
|
218
316
|
'exit $LASTEXITCODE',
|
|
219
317
|
'',
|
|
220
318
|
].join('\r\n');
|
|
221
319
|
}
|
|
222
320
|
if (action === 'disable') {
|
|
223
321
|
return [
|
|
224
|
-
|
|
225
|
-
|
|
322
|
+
`${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
|
|
323
|
+
`${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
|
|
226
324
|
'exit 0',
|
|
227
325
|
'',
|
|
228
326
|
].join('\r\n');
|
|
@@ -236,13 +334,19 @@ export function parseSidFromWhoami(stdout) {
|
|
|
236
334
|
return match ? match[1] : null;
|
|
237
335
|
}
|
|
238
336
|
|
|
337
|
+
// `startError` is set when the program did not run at all (not found, timed
|
|
338
|
+
// out): unlike an exit code, that says nothing about the task.
|
|
239
339
|
function defaultExecFileImpl(file, args) {
|
|
240
340
|
return new Promise((resolve) => {
|
|
241
|
-
execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => {
|
|
341
|
+
execFile(file, args, { windowsHide: true, timeout: QUERY_TIMEOUT_MS }, (error, stdout, stderr) => {
|
|
342
|
+
const exitCode = typeof error?.code === 'number' ? error.code : null;
|
|
242
343
|
resolve({
|
|
243
|
-
code: error ? (
|
|
344
|
+
code: error ? (exitCode ?? 1) : 0,
|
|
244
345
|
stdout: String(stdout || ''),
|
|
245
346
|
stderr: String(stderr || ''),
|
|
347
|
+
startError: error && (exitCode === null || error.killed)
|
|
348
|
+
? describeExecError(error)
|
|
349
|
+
: null,
|
|
246
350
|
});
|
|
247
351
|
});
|
|
248
352
|
});
|
|
@@ -262,12 +366,16 @@ export function createWindowsBootAutostartService({
|
|
|
262
366
|
clearTimeoutImpl = clearTimeout,
|
|
263
367
|
now = () => Date.now(),
|
|
264
368
|
logger = console,
|
|
369
|
+
// Full path of a Windows program (schtasks, whoami, powershell).
|
|
370
|
+
systemToolPath = (name) => windowsSystemToolPath(name, { env }),
|
|
265
371
|
} = {}) {
|
|
266
372
|
const supported = platform === 'win32';
|
|
267
373
|
// One elevation in flight at a time; getState() reports it and the
|
|
268
374
|
// completion callback resolves it.
|
|
269
375
|
let pending = null; // { action, startedAt, timer, child }
|
|
270
376
|
let lastError = null;
|
|
377
|
+
// The launcher an enable replaced, kept beside it (see requestEnable).
|
|
378
|
+
let launcherBackupPath = null;
|
|
271
379
|
|
|
272
380
|
const oarRoot = () => resolveOarRoot(env, platform);
|
|
273
381
|
const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
|
|
@@ -278,9 +386,54 @@ export function createWindowsBootAutostartService({
|
|
|
278
386
|
try { logger?.log?.(`[boot-autostart] ${message}`); } catch {}
|
|
279
387
|
}
|
|
280
388
|
|
|
389
|
+
const tool = (name) => systemToolPath(name);
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The task's XML, null when there is no such task, or `{ readError }` when
|
|
393
|
+
* schtasks itself could not run: that says nothing about the task, so it
|
|
394
|
+
* must not read as "missing".
|
|
395
|
+
*/
|
|
281
396
|
async function queryTaskXml(taskName) {
|
|
282
|
-
|
|
283
|
-
|
|
397
|
+
let result;
|
|
398
|
+
try {
|
|
399
|
+
result = await execFileImpl(tool('schtasks'), ['/query', '/tn', taskName, '/xml']);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
result = { code: 1, stdout: '', stderr: '', startError: describeExecError(error) };
|
|
402
|
+
}
|
|
403
|
+
if (result?.startError) {
|
|
404
|
+
log(`schtasks /query ${taskName} did not run: ${result.startError}; ${describeSpawnEnvironment(env, platform)}`);
|
|
405
|
+
// The details stay in the log (they can name folders on the host).
|
|
406
|
+
return { readError: 'schtasks could not be run; the relay log has the details.' };
|
|
407
|
+
}
|
|
408
|
+
return result?.code === 0 ? result.stdout : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readLauncher() {
|
|
412
|
+
try {
|
|
413
|
+
return fsImpl.existsSync(launcherPath()) ? String(fsImpl.readFileSync(launcherPath(), 'utf8')) : null;
|
|
414
|
+
} catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function generatedLauncher() {
|
|
420
|
+
return buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** What differs from the generated task and launcher, for a task that runs our launcher. */
|
|
424
|
+
function customisationsOf(xmlText) {
|
|
425
|
+
const differences = describeTaskCustomisations(xmlText, {
|
|
426
|
+
launcherPath: launcherPath(),
|
|
427
|
+
workingDirectory: oarRoot(),
|
|
428
|
+
});
|
|
429
|
+
const launcher = readLauncher();
|
|
430
|
+
if (launcher === null) differences.push('launcher-missing');
|
|
431
|
+
else {
|
|
432
|
+
try {
|
|
433
|
+
if (!launcherScriptsMatch(launcher, generatedLauncher())) differences.push('launcher');
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|
|
436
|
+
return differences;
|
|
284
437
|
}
|
|
285
438
|
|
|
286
439
|
function startupCmdPaths() {
|
|
@@ -338,16 +491,29 @@ export function createWindowsBootAutostartService({
|
|
|
338
491
|
}
|
|
339
492
|
const ownXml = await queryTaskXml(BOOT_TASK_NAME);
|
|
340
493
|
const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
494
|
+
const readError = ownXml?.readError || null;
|
|
495
|
+
const legacyKnown = typeof legacyXml === 'string';
|
|
496
|
+
let taskStatus;
|
|
497
|
+
if (readError) taskStatus = 'unknown';
|
|
498
|
+
else if (ownXml === null) taskStatus = legacyKnown ? 'legacy' : 'missing';
|
|
499
|
+
else taskStatus = classifyTaskXml(ownXml, { launcherPath: launcherPath() });
|
|
500
|
+
// A task that runs our launcher but is not quite the one OAR registers (no
|
|
501
|
+
// boot delay, an edited launcher) still is boot mode; it is reported,
|
|
502
|
+
// never failed on.
|
|
503
|
+
const customisations = taskStatus === 'ready' ? customisationsOf(ownXml) : [];
|
|
504
|
+
let launcherPresent = false;
|
|
505
|
+
try { launcherPresent = fsImpl.existsSync(launcherPath()); } catch {}
|
|
344
506
|
return {
|
|
345
507
|
supported: true,
|
|
346
508
|
platform,
|
|
347
509
|
taskStatus,
|
|
348
510
|
taskName: BOOT_TASK_NAME,
|
|
349
|
-
legacyTaskPresent:
|
|
350
|
-
launcherPath:
|
|
511
|
+
legacyTaskPresent: legacyKnown,
|
|
512
|
+
launcherPath: launcherPresent ? launcherPath() : null,
|
|
513
|
+
customised: customisations.length > 0,
|
|
514
|
+
customisations,
|
|
515
|
+
launcherBackupPath,
|
|
516
|
+
readError,
|
|
351
517
|
pendingElevation: pending ? pending.action : null,
|
|
352
518
|
lastError,
|
|
353
519
|
manualCommand: manualCommandFor(pending?.action || 'enable'),
|
|
@@ -374,12 +540,13 @@ export function createWindowsBootAutostartService({
|
|
|
374
540
|
* script, so its exit is the "elevation finished (or was declined)" signal.
|
|
375
541
|
*/
|
|
376
542
|
function spawnElevated(action, onSettled) {
|
|
377
|
-
const
|
|
543
|
+
const powershell = tool('powershell.exe');
|
|
544
|
+
const child = spawnImpl(powershell, [
|
|
378
545
|
'-NoProfile',
|
|
379
546
|
'-NonInteractive',
|
|
380
547
|
'-Command',
|
|
381
548
|
[
|
|
382
|
-
|
|
549
|
+
`$p = Start-Process -FilePath '${powershell.replaceAll("'", "''")}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList`,
|
|
383
550
|
`'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
|
|
384
551
|
'; exit $p.ExitCode',
|
|
385
552
|
].join(' '),
|
|
@@ -425,8 +592,24 @@ export function createWindowsBootAutostartService({
|
|
|
425
592
|
return { ...(await getState()), accepted: false };
|
|
426
593
|
}
|
|
427
594
|
lastError = null;
|
|
428
|
-
|
|
429
|
-
const
|
|
595
|
+
const launcher = generatedLauncher();
|
|
596
|
+
const previous = readLauncher();
|
|
597
|
+
if (previous !== null && !launcherScriptsMatch(previous, launcher)) {
|
|
598
|
+
// An edited (or older) launcher is replaced, but kept: one copy, the
|
|
599
|
+
// latest replaced one.
|
|
600
|
+
const backupPath = `${launcherPath()}${BOOT_LAUNCHER_BACKUP_SUFFIX}`;
|
|
601
|
+
try {
|
|
602
|
+
fsImpl.writeFileSync(backupPath, previous);
|
|
603
|
+
launcherBackupPath = backupPath;
|
|
604
|
+
log(`the launcher differed from the generated one; saved it as ${backupPath}`);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
log(`could not save the current launcher as ${backupPath}: ${error?.message || error}`);
|
|
607
|
+
lastError = 'Could not keep a copy of the current launcher next to it, so it was not replaced; the relay log has the details.';
|
|
608
|
+
return { ...(await getState()), accepted: false };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
writeFileAtomic(launcherPath(), launcher);
|
|
612
|
+
const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
|
|
430
613
|
const sid = parseSidFromWhoami(who.stdout);
|
|
431
614
|
if (!sid) {
|
|
432
615
|
lastError = 'Could not determine the current user SID (whoami /user failed).';
|
|
@@ -438,7 +621,7 @@ export function createWindowsBootAutostartService({
|
|
|
438
621
|
packageRoot: pathImpl.resolve(String(packageRoot)),
|
|
439
622
|
workingDirectory: oarRoot(),
|
|
440
623
|
})));
|
|
441
|
-
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath() }));
|
|
624
|
+
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath(), schtasksPath: tool('schtasks') }));
|
|
442
625
|
spawnElevated('enable', (ok) => {
|
|
443
626
|
if (ok) {
|
|
444
627
|
// Mutual exclusion: boot mode owns startup now, so the sign-in
|
|
@@ -463,7 +646,8 @@ export function createWindowsBootAutostartService({
|
|
|
463
646
|
return { ...(await getState()), accepted: false };
|
|
464
647
|
}
|
|
465
648
|
lastError = null;
|
|
466
|
-
|
|
649
|
+
launcherBackupPath = null;
|
|
650
|
+
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable', schtasksPath: tool('schtasks') }));
|
|
467
651
|
spawnElevated('disable', (ok) => {
|
|
468
652
|
if (ok) {
|
|
469
653
|
try {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// How long a backgrounded Bash command of a Claude session may run before
|
|
2
|
+
// Claude Code stops it, when the agent passes no `timeout` of its own. The
|
|
3
|
+
// CLI's own default is 30 minutes; long test or GPU runs were cut off there.
|
|
4
|
+
//
|
|
5
|
+
// Shared so the settings slider, the relay that stores the value and the
|
|
6
|
+
// worker that hands it to the CLI agree on bounds, steps and formatting. The
|
|
7
|
+
// browser mirrors the default in
|
|
8
|
+
// server/public/app/claude-background-command-limit-options.mjs (only
|
|
9
|
+
// server/public is served), and the slider's static markup in
|
|
10
|
+
// server/public/index.html starts at it; unit tests pin both to this module.
|
|
11
|
+
|
|
12
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
|
|
13
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
|
|
14
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
|
|
15
|
+
export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
|
|
16
|
+
|
|
17
|
+
/** Clamped to 30 min … 8 h and snapped to the slider's 30-minute steps. */
|
|
18
|
+
export function normalizeClaudeBackgroundCommandLimitMinutes(value, fallback = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES) {
|
|
19
|
+
if (value === null || value === undefined) return fallback;
|
|
20
|
+
if (typeof value === 'string' && value.trim() === '') return fallback;
|
|
21
|
+
if (typeof value !== 'number' && typeof value !== 'string') return fallback;
|
|
22
|
+
const numeric = Number(value);
|
|
23
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
24
|
+
const step = CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES;
|
|
25
|
+
const snapped = Math.round(numeric / step) * step;
|
|
26
|
+
return Math.min(
|
|
27
|
+
CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
|
|
28
|
+
Math.max(CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES, snapped),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function claudeBackgroundCommandLimitMinutesToMs(minutes) {
|
|
33
|
+
return normalizeClaudeBackgroundCommandLimitMinutes(minutes) * 60_000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES * 60_000;
|
|
37
|
+
|
|
38
|
+
/** An unset setting means "never configured", which is the default. */
|
|
39
|
+
export function readClaudeBackgroundCommandLimitSetting(storedValue) {
|
|
40
|
+
const text = storedValue === null || storedValue === undefined ? '' : String(storedValue).trim();
|
|
41
|
+
return normalizeClaudeBackgroundCommandLimitMinutes(text);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseClaudeBackgroundCommandLimitUpdate(value) {
|
|
45
|
+
const minutes = normalizeClaudeBackgroundCommandLimitMinutes(value, null);
|
|
46
|
+
if (minutes === null) return { ok: false, error: 'backgroundCommandLimitMinutes must be a number' };
|
|
47
|
+
return { ok: true, minutes };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatClaudeBackgroundCommandLimitLabel(minutes) {
|
|
51
|
+
const normalized = normalizeClaudeBackgroundCommandLimitMinutes(minutes);
|
|
52
|
+
if (normalized < 60) return `${normalized} min`;
|
|
53
|
+
const hours = Math.floor(normalized / 60);
|
|
54
|
+
const remainder = normalized % 60;
|
|
55
|
+
return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The fields every Claude settings payload carries for the slider. */
|
|
59
|
+
export function claudeBackgroundCommandLimitSettingsFields(settings = {}) {
|
|
60
|
+
return {
|
|
61
|
+
backgroundCommandLimitMinutes: normalizeClaudeBackgroundCommandLimitMinutes(settings?.backgroundCommandLimitMinutes),
|
|
62
|
+
backgroundCommandLimitMinMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES,
|
|
63
|
+
backgroundCommandLimitMaxMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
|
|
64
|
+
backgroundCommandLimitStepMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES,
|
|
65
|
+
backgroundCommandLimitDefaultMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What a delivery's `settings` does to the worker's limit (ms): an absent or
|
|
71
|
+
* junk key (an older relay) keeps the last known value; a present one is
|
|
72
|
+
* clamped like the setting itself.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveDeliveredClaudeBackgroundCommandLimitMs(current, settings) {
|
|
75
|
+
const kept = Number.isFinite(Number(current)) && Number(current) > 0
|
|
76
|
+
? claudeBackgroundCommandLimitMinutesToMs(Number(current) / 60_000)
|
|
77
|
+
: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
|
|
78
|
+
if (!settings || typeof settings !== 'object') return kept;
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(settings, 'claudeBackgroundCommandLimitMs')) return kept;
|
|
80
|
+
const delivered = Number(settings.claudeBackgroundCommandLimitMs);
|
|
81
|
+
if (settings.claudeBackgroundCommandLimitMs === null || !Number.isFinite(delivered) || delivered <= 0) return kept;
|
|
82
|
+
return claudeBackgroundCommandLimitMinutesToMs(delivered / 60_000);
|
|
83
|
+
}
|
|
@@ -25,8 +25,10 @@ export const PREVIEW_TOOL_DESCRIPTION =
|
|
|
25
25
|
+ 'paths will not work. For plain files or a build output: pass {action:'
|
|
26
26
|
+ '"create", dir} instead and the relay serves the directory itself — no dev '
|
|
27
27
|
+ 'server needed. Always tell the user the returned URL and that the link is '
|
|
28
|
-
+ 'public: anyone who has it can reach the app without logging in.
|
|
29
|
-
+ '
|
|
28
|
+
+ 'public: anyone who has it can reach the app without logging in. Publishing '
|
|
29
|
+
+ 'the same port or directory again in this conversation returns the same '
|
|
30
|
+
+ 'link. Previews never expire and survive a relay restart (a port preview '
|
|
31
|
+
+ 'until the machine reboots); close them with {action:"close"} when the user '
|
|
30
32
|
+ 'is done.';
|
|
31
33
|
|
|
32
34
|
export const PREVIEW_TOOL_INPUT_SCHEMA = {
|
|
@@ -126,6 +128,9 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
126
128
|
target: entry.mode === 'static' ? entry.rootDir : `localhost:${entry.targetPort}`,
|
|
127
129
|
online: entry.online,
|
|
128
130
|
conversationId: entry.conversationId,
|
|
131
|
+
// Stored but not served: the relay could not check it after a
|
|
132
|
+
// restart and tries again. It can be closed.
|
|
133
|
+
...(entry.unverified === true ? { unverified: true } : {}),
|
|
129
134
|
})),
|
|
130
135
|
};
|
|
131
136
|
}
|
|
@@ -155,15 +160,20 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
155
160
|
...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
|
|
156
161
|
};
|
|
157
162
|
const response = await api('POST', '/api/previews', body);
|
|
163
|
+
const existing = response?.existing === true;
|
|
164
|
+
const hint = parsed.port
|
|
165
|
+
? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
|
|
166
|
+
: 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.';
|
|
158
167
|
return {
|
|
159
168
|
ok: true,
|
|
169
|
+
existing,
|
|
160
170
|
url: response?.url,
|
|
161
171
|
basePath: response?.basePath,
|
|
162
172
|
token: response?.preview?.token,
|
|
163
173
|
mode: response?.preview?.mode || (parsed.port ? 'port' : 'static'),
|
|
164
|
-
hint:
|
|
165
|
-
? `
|
|
166
|
-
:
|
|
174
|
+
hint: existing
|
|
175
|
+
? `This conversation already publishes this ${parsed.port ? 'port' : 'directory'}: same link as before, label unchanged. ${hint}`
|
|
176
|
+
: hint,
|
|
167
177
|
};
|
|
168
178
|
} catch (error) {
|
|
169
179
|
return { ok: false, error: String(error?.message || error) };
|
|
@@ -37,6 +37,7 @@ export const REMOTE_RELAY_ACTIONS = Object.freeze([
|
|
|
37
37
|
'usage',
|
|
38
38
|
'list_sessions',
|
|
39
39
|
'read_session',
|
|
40
|
+
'read_task_output',
|
|
40
41
|
'wait',
|
|
41
42
|
'send',
|
|
42
43
|
'create_session',
|
|
@@ -63,6 +64,7 @@ export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
|
|
|
63
64
|
usage: 'read',
|
|
64
65
|
list_sessions: 'read',
|
|
65
66
|
read_session: 'read',
|
|
67
|
+
read_task_output: 'read',
|
|
66
68
|
wait: 'read',
|
|
67
69
|
send: 'prompt',
|
|
68
70
|
create_session: 'full',
|
|
@@ -120,6 +122,11 @@ export const REMOTE_RELAY_LIMITS = Object.freeze({
|
|
|
120
122
|
listMax: 100,
|
|
121
123
|
readDefault: 10,
|
|
122
124
|
readMax: 50,
|
|
125
|
+
// read_task_output: lines of a background task's output, and the most
|
|
126
|
+
// text one call returns.
|
|
127
|
+
taskOutputLinesDefault: 200,
|
|
128
|
+
taskOutputLinesMax: 500,
|
|
129
|
+
taskOutputBytesMax: 32 * 1024,
|
|
123
130
|
maxCharsDefault: 12000,
|
|
124
131
|
maxCharsMax: 50000,
|
|
125
132
|
perMessageChars: 4000,
|
|
@@ -217,7 +224,9 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
217
224
|
+ 'with cwd, provider "claude-cloud" with repo and an optional branch), then wait for and read '
|
|
218
225
|
+ 'each one. The first create_session here shows the user an approval card once per '
|
|
219
226
|
+ 'conversation. A session you created cannot create sessions itself, and every session '
|
|
220
|
-
+ 'runs on the relay owner\'s accounts, so start only what the task needs.
|
|
227
|
+
+ 'runs on the relay owner\'s accounts, so start only what the task needs. Sessions you start '
|
|
228
|
+
+ 'here are grouped under this session in the owner\'s sidebar; list_sessions and read_session '
|
|
229
|
+
+ 'show the group (read-only for agents, the owner arranges groups). A new session '
|
|
221
230
|
+ 'knows nothing of this conversation: write a self-contained prompt, and for '
|
|
222
231
|
+ '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
|
|
223
232
|
+ 'work on and to push. '
|
|
@@ -233,6 +242,9 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
233
242
|
+ 'usage reports what the provider plans of a relay have left (Copilot, Claude, Claude Cloud, Cursor, Grok: '
|
|
234
243
|
+ 'meters with used, allowance, remaining, utilization and reset time; providers not set up or not tracked say so) '
|
|
235
244
|
+ 'and whether a Claude usage limit has paused turns there, before you start work that costs. '
|
|
245
|
+
+ 'read_task_output {session, task_id} reads the end of a background shell or Monitor task\'s output there '
|
|
246
|
+
+ '(task ids from read_session\'s tasks; also for an hour after the task ended): the last lines, minutes '
|
|
247
|
+
+ 'since its last output, and pass/fail counts when it is a test run; credentials are scrubbed. '
|
|
236
248
|
+ 'Event subscriptions: subscribe to sessions of this relay or of a paired relay (ids, or "all" for every '
|
|
237
249
|
+ 'session there, later ones too) and the relay wakes you with a message when something happens there: '
|
|
238
250
|
+ 'question_asked (with the question and its options), error (failed turn, usage limit, worker died), '
|
|
@@ -261,7 +273,8 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
261
273
|
type: 'string',
|
|
262
274
|
enum: [...REMOTE_RELAY_ACTIONS],
|
|
263
275
|
description: 'list_relays: the relays (this one included when allowed) and whether each is unlocked here. relay_info: providers, models, '
|
|
264
|
-
+ 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions.
|
|
276
|
+
+ 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions. '
|
|
277
|
+
+ 'read_task_output: the end of a background shell task\'s output. send: prompt an '
|
|
265
278
|
+ 'existing session. create_session: start a new session with a first prompt. wait: keep waiting for '
|
|
266
279
|
+ 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
|
|
267
280
|
+ 'archive: archive a session. subscribe / unsubscribe / list_subscriptions / transfer_subscriptions: '
|
|
@@ -369,6 +382,13 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
369
382
|
subscription_id: { type: 'string', description: 'unsubscribe: the id subscribe returned, or "all".' },
|
|
370
383
|
watcher: { type: 'string', description: 'list_subscriptions: another conversation\'s id, to see its subscriptions (default: this one).' },
|
|
371
384
|
from: { type: 'string', description: 'transfer_subscriptions: the id of the conversation whose subscriptions move here.' },
|
|
385
|
+
task_id: { type: 'string', description: 'read_task_output: the background task\'s id (taskId in read_session\'s tasks).' },
|
|
386
|
+
lines: {
|
|
387
|
+
type: 'integer',
|
|
388
|
+
minimum: 1,
|
|
389
|
+
maximum: REMOTE_RELAY_LIMITS.taskOutputLinesMax,
|
|
390
|
+
description: `read_task_output: how many of the last output lines (default ${REMOTE_RELAY_LIMITS.taskOutputLinesDefault}).`,
|
|
391
|
+
},
|
|
372
392
|
},
|
|
373
393
|
required: ['action'],
|
|
374
394
|
});
|
|
@@ -388,7 +408,7 @@ function invalid(error) {
|
|
|
388
408
|
return { ok: false, code: REMOTE_RELAY_ERROR_CODES.invalidInput, error };
|
|
389
409
|
}
|
|
390
410
|
|
|
391
|
-
const SESSION_ACTIONS = new Set(['read_session', 'wait', 'send', 'stop', 'archive']);
|
|
411
|
+
const SESSION_ACTIONS = new Set(['read_session', 'read_task_output', 'wait', 'send', 'stop', 'archive']);
|
|
392
412
|
|
|
393
413
|
/**
|
|
394
414
|
* The longest wait a relay may be set to allow, from whatever was stored or
|
|
@@ -493,6 +513,18 @@ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}
|
|
|
493
513
|
args.max_chars = maxChars;
|
|
494
514
|
args.include_activity = input?.include_activity === true || toText(input?.include_activity).toLowerCase() === 'true';
|
|
495
515
|
optionalText('before', 1000);
|
|
516
|
+
} else if (action === 'read_task_output') {
|
|
517
|
+
const taskId = toText(input?.task_id);
|
|
518
|
+
if (!taskId) return invalid('read_task_output needs task_id (a taskId from read_session\'s tasks)');
|
|
519
|
+
if (taskId.length > 200 || /[\s/\\]/.test(taskId)) return invalid('task_id is not a task id');
|
|
520
|
+
args.task_id = taskId;
|
|
521
|
+
const lines = clampInteger(input?.lines, {
|
|
522
|
+
min: 1,
|
|
523
|
+
max: REMOTE_RELAY_LIMITS.taskOutputLinesMax,
|
|
524
|
+
fallback: REMOTE_RELAY_LIMITS.taskOutputLinesDefault,
|
|
525
|
+
});
|
|
526
|
+
if (lines === null) return invalid('lines must be a number');
|
|
527
|
+
args.lines = lines;
|
|
496
528
|
} else if (action === 'wait') {
|
|
497
529
|
const messageId = toText(input?.message_id);
|
|
498
530
|
if (!messageId) return invalid('wait needs message_id (from send or create_session)');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// What a shared conversation shows of the relay host's paths and file names.
|
|
2
|
+
// One relay-wide setting; the owner's own view is never filtered. Shared so the
|
|
3
|
+
// settings route, the share routes and the Settings control agree.
|
|
4
|
+
|
|
5
|
+
export const SHARE_PATH_PRIVACY_SETTING_KEY = 'share_path_privacy';
|
|
6
|
+
|
|
7
|
+
export const SHARE_PATH_PRIVACY_MODES = Object.freeze(['show', 'filename', 'hide']);
|
|
8
|
+
export const DEFAULT_SHARE_PATH_PRIVACY_MODE = 'filename';
|
|
9
|
+
|
|
10
|
+
export const SHARE_PATH_PRIVACY_LABELS = Object.freeze({
|
|
11
|
+
show: 'Show every path and file name',
|
|
12
|
+
filename: 'Hide the path, show the file name',
|
|
13
|
+
hide: 'Hide paths and file names',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function asMode(value) {
|
|
17
|
+
const mode = String(value ?? '').trim().toLowerCase();
|
|
18
|
+
return SHARE_PATH_PRIVACY_MODES.includes(mode) ? mode : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An unset or unreadable setting means the default, never "show". */
|
|
22
|
+
export function readSharePathPrivacySetting(storedValue) {
|
|
23
|
+
return asMode(storedValue) || DEFAULT_SHARE_PATH_PRIVACY_MODE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseSharePathPrivacyUpdate(value) {
|
|
27
|
+
const mode = asMode(value);
|
|
28
|
+
if (!mode) return { ok: false, error: `mode must be one of ${SHARE_PATH_PRIVACY_MODES.join(', ')}` };
|
|
29
|
+
return { ok: true, mode };
|
|
30
|
+
}
|