@oar-sh/oar 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -6,6 +6,16 @@ import os from 'os';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
|
|
9
|
+
import {
|
|
10
|
+
OAR_MCP_SERVER_NAME,
|
|
11
|
+
OAR_MCP_SERVER_SCRIPT_PATH,
|
|
12
|
+
OAR_MCP_TOOL_TIMEOUT_MS,
|
|
13
|
+
buildOarMcpServerLaunch,
|
|
14
|
+
isOarMcpServerDisabled,
|
|
15
|
+
} from '../mcp/oar-mcp-server.mjs';
|
|
16
|
+
|
|
17
|
+
export { isOarMcpServerDisabled };
|
|
18
|
+
|
|
9
19
|
function shellQuote(value) {
|
|
10
20
|
return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
|
|
11
21
|
}
|
|
@@ -302,7 +312,7 @@ function resolveNodeWorkerDescriptor(env = {}) {
|
|
|
302
312
|
};
|
|
303
313
|
}
|
|
304
314
|
|
|
305
|
-
function buildPosixWorkerLaunchCommand(targetSessionId, env = {}) {
|
|
315
|
+
function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs = []) {
|
|
306
316
|
const nodeWorker = resolveNodeWorkerDescriptor(env);
|
|
307
317
|
if (nodeWorker) {
|
|
308
318
|
const nodeExecutable = normalizeText(env?.COPILOT_WEB_RELAY_NODE) || 'node';
|
|
@@ -312,7 +322,88 @@ function buildPosixWorkerLaunchCommand(targetSessionId, env = {}) {
|
|
|
312
322
|
|| normalizeText(env?.COPILOT_CLI_EXECUTABLE)
|
|
313
323
|
|| normalizeText(env?.COPILOT_CLI_PATH)
|
|
314
324
|
|| 'copilot';
|
|
315
|
-
|
|
325
|
+
const extra = (Array.isArray(extraCliArgs) ? extraCliArgs : []).map((arg) => ` ${shellQuote(arg)}`).join('');
|
|
326
|
+
return `${shellQuote(cliExecutable)} --allow-all --session-id ${shellQuote(targetSessionId)}${extra}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── The OAR MCP server for the extension engine ─────────────────────────────
|
|
330
|
+
//
|
|
331
|
+
// The Copilot CLI takes extra MCP servers per run through
|
|
332
|
+
// `--additional-mcp-config <json>`, where `<json>` may be `@<file>` (CLI
|
|
333
|
+
// 1.0.83 `--help`: "JSON string or file path (prefix with @)"; the file is
|
|
334
|
+
// read as UTF-8 and merged over ~/.copilot/mcp-config.json for that run
|
|
335
|
+
// only). The config goes to a file rather than inline: the argument then
|
|
336
|
+
// crosses cmd.exe/`start` on Windows and `sh -lc` + `script -c` on the tmux
|
|
337
|
+
// path as one plain path, with no JSON to escape through two shells.
|
|
338
|
+
//
|
|
339
|
+
// Server entry shape (the CLI's own `copilot mcp add` writes the same):
|
|
340
|
+
// `{ type: 'local', command, args, env, tools: ['*'], timeout }`. `timeout` is
|
|
341
|
+
// the per-call limit in ms; without it the CLI cuts a tool call off at 180 s,
|
|
342
|
+
// shorter than one remote_relay wait.
|
|
343
|
+
|
|
344
|
+
export function resolveOarMcpServerScriptPath(env = {}, { pathImpl = path } = {}) {
|
|
345
|
+
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
346
|
+
if (repoRoot) return pathImpl.join(repoRoot, 'server', 'mcp', 'oar-mcp-server.mjs');
|
|
347
|
+
const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
348
|
+
if (serverDir) return pathImpl.join(serverDir, 'mcp', 'oar-mcp-server.mjs');
|
|
349
|
+
return OAR_MCP_SERVER_SCRIPT_PATH;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The `--additional-mcp-config` document for one extension-engine session. */
|
|
353
|
+
export function buildCopilotOarMcpConfig(targetSessionId, env = {}, {
|
|
354
|
+
nodePath = normalizeText(env?.COPILOT_WEB_RELAY_NODE) || process.execPath,
|
|
355
|
+
pathImpl = path,
|
|
356
|
+
} = {}) {
|
|
357
|
+
const launch = buildOarMcpServerLaunch({
|
|
358
|
+
nodePath,
|
|
359
|
+
conversationId: targetSessionId,
|
|
360
|
+
env,
|
|
361
|
+
scriptPath: resolveOarMcpServerScriptPath(env, { pathImpl }),
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
mcpServers: {
|
|
365
|
+
[OAR_MCP_SERVER_NAME]: {
|
|
366
|
+
type: 'local',
|
|
367
|
+
command: launch.command,
|
|
368
|
+
args: launch.args,
|
|
369
|
+
env: launch.env,
|
|
370
|
+
tools: ['*'],
|
|
371
|
+
timeout: OAR_MCP_TOOL_TIMEOUT_MS,
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Writes the session's MCP config next to its worker log and returns the file
|
|
379
|
+
* path, or null — best-effort like the log itself: a session that cannot get
|
|
380
|
+
* the file still starts, just without the OAR tools. No secrets go in: the
|
|
381
|
+
* server reads the token from the relay config the file points at.
|
|
382
|
+
*/
|
|
383
|
+
export function prepareOarMcpConfigFile(targetSessionId, launchEnv = {}, {
|
|
384
|
+
fsImpl = fs,
|
|
385
|
+
pathImpl = path,
|
|
386
|
+
nodePath = undefined,
|
|
387
|
+
} = {}) {
|
|
388
|
+
if (isOarMcpServerDisabled(launchEnv)) return null;
|
|
389
|
+
try {
|
|
390
|
+
const config = buildCopilotOarMcpConfig(targetSessionId, launchEnv, {
|
|
391
|
+
pathImpl,
|
|
392
|
+
...(nodePath ? { nodePath } : {}),
|
|
393
|
+
});
|
|
394
|
+
const baseDir = resolveWorkerLogDir(launchEnv, { pathImpl });
|
|
395
|
+
fsImpl.mkdirSync(baseDir, { recursive: true });
|
|
396
|
+
const filePath = pathImpl.join(baseDir, `worker-${safeWorkerFileId(targetSessionId)}.mcp.json`);
|
|
397
|
+
fsImpl.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
398
|
+
return filePath;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function oarMcpCliArgs(configFilePath) {
|
|
405
|
+
const filePath = normalizeText(configFilePath);
|
|
406
|
+
return filePath ? ['--additional-mcp-config', `@${filePath}`] : [];
|
|
316
407
|
}
|
|
317
408
|
|
|
318
409
|
export function normalizeTmuxSessionName(targetSessionId) {
|
|
@@ -418,20 +509,28 @@ export function createWorkerSecretEnvFile(env = {}, {
|
|
|
418
509
|
* incident undiagnosable. Best-effort by design: a log problem must never
|
|
419
510
|
* block a worker spawn. Naive rotation: >10 MB rolls to `<file>.1`.
|
|
420
511
|
*/
|
|
512
|
+
function resolveWorkerLogDir(launchEnv = {}, { pathImpl = path } = {}) {
|
|
513
|
+
const explicitDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_LOG_DIR);
|
|
514
|
+
const serverDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
515
|
+
// `new URL(...).pathname` yields '/C:/git/...' on Windows, which pathImpl.join
|
|
516
|
+
// then mangles into '\C:\git\...'; fileURLToPath decodes it to a real host path.
|
|
517
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
518
|
+
return explicitDir
|
|
519
|
+
|| (serverDir ? pathImpl.join(serverDir, 'logs') : pathImpl.join(moduleDir, '..', 'logs'));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function safeWorkerFileId(targetSessionId) {
|
|
523
|
+
return String(targetSessionId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'worker';
|
|
524
|
+
}
|
|
525
|
+
|
|
421
526
|
export function prepareWorkerLogFile(targetSessionId, launchEnv = {}, {
|
|
422
527
|
fsImpl = fs,
|
|
423
528
|
pathImpl = path,
|
|
424
529
|
} = {}) {
|
|
425
530
|
try {
|
|
426
|
-
const
|
|
427
|
-
const serverDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
428
|
-
// `new URL(...).pathname` yields '/C:/git/...' on Windows, which pathImpl.join
|
|
429
|
-
// then mangles into '\C:\git\...'; fileURLToPath decodes it to a real host path.
|
|
430
|
-
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
431
|
-
const baseDir = explicitDir
|
|
432
|
-
|| (serverDir ? pathImpl.join(serverDir, 'logs') : pathImpl.join(moduleDir, '..', 'logs'));
|
|
531
|
+
const baseDir = resolveWorkerLogDir(launchEnv, { pathImpl });
|
|
433
532
|
fsImpl.mkdirSync(baseDir, { recursive: true });
|
|
434
|
-
const safeId =
|
|
533
|
+
const safeId = safeWorkerFileId(targetSessionId);
|
|
435
534
|
const logPath = pathImpl.join(baseDir, `worker-${safeId}.log`);
|
|
436
535
|
try {
|
|
437
536
|
const stats = fsImpl.statSync(logPath);
|
|
@@ -446,6 +545,9 @@ export function prepareWorkerLogFile(targetSessionId, launchEnv = {}, {
|
|
|
446
545
|
export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
447
546
|
secretEnvFilePath = '',
|
|
448
547
|
workerLogPath = '',
|
|
548
|
+
// Extension engine only: appended to the CLI command, each shell-quoted
|
|
549
|
+
// (and quoted again with it for `script -c`).
|
|
550
|
+
extraCliArgs = [],
|
|
449
551
|
} = {}) {
|
|
450
552
|
const launchEnv = {
|
|
451
553
|
...env,
|
|
@@ -503,6 +605,10 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
503
605
|
'GROK_RELAY_MODEL',
|
|
504
606
|
'GROK_CLI_COMMAND',
|
|
505
607
|
'GROK_ALWAYS_APPROVE',
|
|
608
|
+
// The OAR MCP server kill switch (the Grok worker reads it too), and the
|
|
609
|
+
// extension engine's "it is attached" marker.
|
|
610
|
+
'OAR_MCP_SERVER',
|
|
611
|
+
'OAR_MCP_SERVER_ATTACHED',
|
|
506
612
|
'SESSION_ID',
|
|
507
613
|
'COPILOT_WORKSPACE_ROOT',
|
|
508
614
|
'INIT_CWD',
|
|
@@ -515,7 +621,7 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
515
621
|
const secretPrefix = normalizedSecretEnvFilePath
|
|
516
622
|
? `. ${shellQuote(normalizedSecretEnvFilePath)} || exit $?; rm -f ${shellQuote(normalizedSecretEnvFilePath)}; rmdir ${shellQuote(path.dirname(normalizedSecretEnvFilePath))}; `
|
|
517
623
|
: '';
|
|
518
|
-
const workerCommand = buildPosixWorkerLaunchCommand(targetSessionId, launchEnv);
|
|
624
|
+
const workerCommand = buildPosixWorkerLaunchCommand(targetSessionId, launchEnv, extraCliArgs);
|
|
519
625
|
if (resolveNodeWorkerDescriptor(launchEnv)) {
|
|
520
626
|
// Node workers (Claude, Cursor, Grok) are plain processes; no pseudo-TTY
|
|
521
627
|
// needed. Their output is teed to the worker log so a crash leaves a
|
|
@@ -574,6 +680,9 @@ export async function launchSessionCli({
|
|
|
574
680
|
detachedPollDelayMs = 200,
|
|
575
681
|
allowProcessReuse = true,
|
|
576
682
|
createSecretEnvFileImpl = createWorkerSecretEnvFile,
|
|
683
|
+
// Extension engine: writes the session's OAR MCP config, returns its path
|
|
684
|
+
// (or null to launch without it).
|
|
685
|
+
prepareOarMcpConfigImpl = prepareOarMcpConfigFile,
|
|
577
686
|
} = {}) {
|
|
578
687
|
const target = String(targetSessionId || '').trim();
|
|
579
688
|
if (!target) throw new Error('missing-target-session-id');
|
|
@@ -610,6 +719,14 @@ export async function launchSessionCli({
|
|
|
610
719
|
...launchEnv,
|
|
611
720
|
SESSION_ID: target,
|
|
612
721
|
};
|
|
722
|
+
// The extension engine gets the OAR MCP server (remote_relay + preview) the
|
|
723
|
+
// node workers carry in-process; every launch path appends the same args.
|
|
724
|
+
const extensionCliArgs = resolveNodeWorkerDescriptor(launchSessionEnv)
|
|
725
|
+
? []
|
|
726
|
+
: oarMcpCliArgs(prepareOarMcpConfigImpl(target, launchSessionEnv));
|
|
727
|
+
// Tells the in-CLI web-relay extension that `preview` is a real tool in this
|
|
728
|
+
// session, so its prompt context need not teach the HTTP fallback.
|
|
729
|
+
if (extensionCliArgs.length) launchSessionEnv.OAR_MCP_SERVER_ATTACHED = '1';
|
|
613
730
|
|
|
614
731
|
if (isTmuxAvailable({ platform, execFileSyncImpl })) {
|
|
615
732
|
const sessionName = normalizeTmuxSessionName(target);
|
|
@@ -647,6 +764,7 @@ export async function launchSessionCli({
|
|
|
647
764
|
buildTmuxWorkerShellCommand(target, launchSessionEnv, {
|
|
648
765
|
secretEnvFilePath: secretEnvFile?.filePath,
|
|
649
766
|
workerLogPath: tmuxWorkerLogPath || '',
|
|
767
|
+
extraCliArgs: extensionCliArgs,
|
|
650
768
|
}),
|
|
651
769
|
], {
|
|
652
770
|
env: tmuxEnv,
|
|
@@ -717,10 +835,11 @@ export async function launchSessionCli({
|
|
|
717
835
|
'--allow-all',
|
|
718
836
|
'--session-id',
|
|
719
837
|
target,
|
|
838
|
+
...extensionCliArgs,
|
|
720
839
|
])
|
|
721
840
|
: (nodeWorker
|
|
722
841
|
? [nodeWorker.scriptPath, '--session-id', target]
|
|
723
|
-
: ['--allow-all', '--session-id', target]);
|
|
842
|
+
: ['--allow-all', '--session-id', target, ...extensionCliArgs]);
|
|
724
843
|
// POSIX node workers tee stdout/stderr into the worker log (a crash must
|
|
725
844
|
// leave a trail). On win32 the `start` intermediary opens its own console,
|
|
726
845
|
// so fd inheritance cannot reach the worker — its window shows the output.
|
|
@@ -60,6 +60,54 @@ function normalizeStatus(value) {
|
|
|
60
60
|
return ALLOWED_STATUSES.has(mapped) ? mapped : 'new';
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'other']);
|
|
64
|
+
const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
|
|
65
|
+
const STEERING_CANCELLABLE_IDS_MAX = 50;
|
|
66
|
+
|
|
67
|
+
// Queue message ids the worker still holds pushed-but-unconsumed (the Copilot
|
|
68
|
+
// SDK's queued lane): trimmed strings only, deduped, capped — a worker cannot
|
|
69
|
+
// grow the status payload without bound.
|
|
70
|
+
function normalizeSteeringCancellableIds(raw) {
|
|
71
|
+
if (!Array.isArray(raw)) return [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const ids = [];
|
|
74
|
+
for (const value of raw) {
|
|
75
|
+
if (typeof value !== 'string') continue;
|
|
76
|
+
const id = value.trim();
|
|
77
|
+
if (!id || id.length > STEERING_MESSAGE_ID_MAX_LENGTH || seen.has(id)) continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
ids.push(id);
|
|
80
|
+
if (ids.length >= STEERING_CANCELLABLE_IDS_MAX) break;
|
|
81
|
+
}
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The worker's composer-facing steering snapshot, published on its heartbeat.
|
|
86
|
+
// Callers follow the registry convention of spreading the existing entry into
|
|
87
|
+
// upserts, so an update without `steering` keeps the last known snapshot.
|
|
88
|
+
// Exported so the heartbeat route compares apples to apples: normalizing there
|
|
89
|
+
// with different rules would make an unknown holdReason never converge and
|
|
90
|
+
// re-upsert on every heartbeat.
|
|
91
|
+
//
|
|
92
|
+
// `supported` is the worker's opt-in to mid-turn steering and the client
|
|
93
|
+
// composer's only steering gate: a worker that never sets it — the extension
|
|
94
|
+
// path, or a Claude worker old enough to send the 4-field snapshot — keeps
|
|
95
|
+
// "Queue". `cancellableIds` lists the pushed rows the worker can still pull
|
|
96
|
+
// back out of its runtime (un-steer).
|
|
97
|
+
export function normalizeWorkerSteeringSnapshot(raw) {
|
|
98
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
99
|
+
const holdReason = String(raw.holdReason || '').trim().toLowerCase();
|
|
100
|
+
const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
|
|
101
|
+
return {
|
|
102
|
+
turnActive: raw.turnActive === true,
|
|
103
|
+
canSteer: raw.canSteer === true,
|
|
104
|
+
holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
|
|
105
|
+
messageId: messageId || null,
|
|
106
|
+
supported: raw.supported === true,
|
|
107
|
+
cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
63
111
|
function sanitizeState(raw = null) {
|
|
64
112
|
const nowIso = new Date().toISOString();
|
|
65
113
|
const base = raw && typeof raw === 'object' ? raw : {};
|
|
@@ -74,11 +122,23 @@ function sanitizeState(raw = null) {
|
|
|
74
122
|
queueDepth: toNonNegativeInt(base.queueDepth, 0),
|
|
75
123
|
retryCount: toNonNegativeInt(base.retryCount, 0),
|
|
76
124
|
lastError: String(base.lastError || '').trim() || null,
|
|
125
|
+
steering: normalizeWorkerSteeringSnapshot(base.steering),
|
|
77
126
|
updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
|
|
78
127
|
createdAt,
|
|
79
128
|
};
|
|
80
129
|
}
|
|
81
130
|
|
|
131
|
+
// Statuses that mean the process a snapshot described is gone or not yet
|
|
132
|
+
// replaced by one that has reported. 'error' counts only on the way IN: a
|
|
133
|
+
// live worker marked errored (a requeue retry) keeps heartbeating, and its
|
|
134
|
+
// next snapshot must stick rather than be cleared on every upsert.
|
|
135
|
+
const WORKER_GONE_STATUSES = new Set(['new', 'starting', 'stopped', 'error']);
|
|
136
|
+
|
|
137
|
+
function isWorkerReplacedOrGone(existing, next) {
|
|
138
|
+
if (existing.pid && next.pid !== existing.pid) return true;
|
|
139
|
+
return next.status !== existing.status && WORKER_GONE_STATUSES.has(next.status);
|
|
140
|
+
}
|
|
141
|
+
|
|
82
142
|
function toSnapshot(entry) {
|
|
83
143
|
return Object.freeze({ ...entry });
|
|
84
144
|
}
|
|
@@ -163,12 +223,26 @@ export function createSessionWorkerRegistry() {
|
|
|
163
223
|
createdAt: existing?.createdAt || state.createdAt,
|
|
164
224
|
updatedAt: new Date().toISOString(),
|
|
165
225
|
});
|
|
226
|
+
// A steering snapshot describes one live worker process. Callers spread
|
|
227
|
+
// the old entry into every upsert, so without this a crashed worker's
|
|
228
|
+
// hold outlived it and the composer showed it until a replacement
|
|
229
|
+
// heartbeated — indefinitely, if none ever did.
|
|
230
|
+
if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
|
|
166
231
|
clearIndexesForSession(existing);
|
|
167
232
|
bySession.set(state.sdkSessionId, next);
|
|
168
233
|
updateIndexesForSession(next);
|
|
169
234
|
return toSnapshot(next);
|
|
170
235
|
}
|
|
171
236
|
|
|
237
|
+
/** Drop a session's steering snapshot (its worker process is known dead). */
|
|
238
|
+
function clearSteering(sdkSessionId) {
|
|
239
|
+
const sessionId = normalizeSessionId(sdkSessionId);
|
|
240
|
+
const existing = sessionId ? bySession.get(sessionId) : null;
|
|
241
|
+
if (!existing?.steering) return false;
|
|
242
|
+
bySession.set(sessionId, { ...existing, steering: null, updatedAt: new Date().toISOString() });
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
172
246
|
function removeWorker(sdkSessionId) {
|
|
173
247
|
const sessionId = normalizeSessionId(sdkSessionId);
|
|
174
248
|
if (!sessionId) return false;
|
|
@@ -224,6 +298,7 @@ export function createSessionWorkerRegistry() {
|
|
|
224
298
|
getWorkerByRuntimeSessionId,
|
|
225
299
|
listWorkers,
|
|
226
300
|
upsertWorker,
|
|
301
|
+
clearSteering,
|
|
227
302
|
removeWorker,
|
|
228
303
|
rekeyWorker,
|
|
229
304
|
clearAll,
|
|
@@ -482,10 +482,16 @@ export function createSessionWorkerSupervisor({
|
|
|
482
482
|
if (current && !isTransitionAllowed(current.status, requestedStatus)) {
|
|
483
483
|
return current;
|
|
484
484
|
}
|
|
485
|
+
// The steering snapshot is the worker's to set (its heartbeat) and the
|
|
486
|
+
// registry's to clear (process gone or replaced). Callers build nextState
|
|
487
|
+
// from an older copy of the entry — ensureWorker from the 'starting' one,
|
|
488
|
+
// taken before the new process heartbeated — so passing theirs through
|
|
489
|
+
// would overwrite the snapshot the worker sent in the meantime.
|
|
490
|
+
const { steering: _staleSteering, ...patch } = nextState;
|
|
485
491
|
const next = registry.upsertWorker({
|
|
486
492
|
...(current || {}),
|
|
487
493
|
sdkSessionId: sessionId,
|
|
488
|
-
...
|
|
494
|
+
...patch,
|
|
489
495
|
status: requestedStatus,
|
|
490
496
|
updatedAt: nowIso(),
|
|
491
497
|
});
|
|
@@ -91,6 +91,13 @@ function queueSnapshotChanged(left, right) {
|
|
|
91
91
|
// heartbeats and a retained socket no longer proves the worker is alive.
|
|
92
92
|
const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 30_000;
|
|
93
93
|
|
|
94
|
+
// Protocol features this relay understands, advertised in server.hello. A
|
|
95
|
+
// worker that updated on disk before the relay restarted must not rely on
|
|
96
|
+
// them otherwise: an older relay ignores worker.unready / hello ready:false
|
|
97
|
+
// and treats a `steering-held` requeue as a failure (retry, backoff,
|
|
98
|
+
// worker marked errored).
|
|
99
|
+
export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held']);
|
|
100
|
+
|
|
94
101
|
export function createSessionWorkerWebSocketService({
|
|
95
102
|
WebSocketServerImpl,
|
|
96
103
|
httpServer,
|
|
@@ -378,6 +385,7 @@ export function createSessionWorkerWebSocketService({
|
|
|
378
385
|
emitEvent(ws, {
|
|
379
386
|
type: 'server.hello',
|
|
380
387
|
reason: 'connected',
|
|
388
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
381
389
|
queue: normalizeQueueSnapshot(queueCounts()),
|
|
382
390
|
sessionId: identity.sessionId,
|
|
383
391
|
timestamp: nowIso(),
|
|
@@ -396,12 +404,15 @@ export function createSessionWorkerWebSocketService({
|
|
|
396
404
|
if (payload.type === 'worker.hello') {
|
|
397
405
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
398
406
|
meta.lastSeenAtMs = nowMsSafe();
|
|
399
|
-
|
|
407
|
+
// A worker holding deliveries (open question card, compaction) binds
|
|
408
|
+
// its identity with hello but must not be handed work.
|
|
409
|
+
meta.ready = payload.ready !== false;
|
|
400
410
|
meta.lastHelloAt = nowIso();
|
|
401
411
|
noteHeartbeat(meta, 'worker-hello');
|
|
402
412
|
emitEvent(ws, {
|
|
403
413
|
type: 'server.hello',
|
|
404
414
|
reason: 'ack',
|
|
415
|
+
capabilities: WORKER_PROTOCOL_CAPABILITIES,
|
|
405
416
|
queue: normalizeQueueSnapshot(queueCounts()),
|
|
406
417
|
sessionId: meta.sessionId,
|
|
407
418
|
timestamp: nowIso(),
|
|
@@ -418,6 +429,17 @@ export function createSessionWorkerWebSocketService({
|
|
|
418
429
|
void maybeDeliverToSocket(ws, String(payload.reason || 'worker-ready'));
|
|
419
430
|
return;
|
|
420
431
|
}
|
|
432
|
+
if (payload.type === 'worker.unready') {
|
|
433
|
+
// Readiness is otherwise sticky until the next delivery: a ready sent
|
|
434
|
+
// while the worker could steer would still stand after a hold began,
|
|
435
|
+
// and the delivery it drew would only be handed back.
|
|
436
|
+
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
437
|
+
meta.lastSeenAtMs = nowMsSafe();
|
|
438
|
+
meta.ready = false;
|
|
439
|
+
meta.lastUnreadyAt = nowIso();
|
|
440
|
+
noteHeartbeat(meta, String(payload.reason || 'worker-unready'));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
421
443
|
if (payload.type === 'worker.ping') {
|
|
422
444
|
if (!bindSocketIdentity(ws, meta, payload)) return;
|
|
423
445
|
meta.lastSeenAtMs = nowMsSafe();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Resend of a steer a Stop cut off unanswered (kind='stopped'): shared by the
|
|
2
|
+
// POST /api/message guard and the transcript payload, so "Resent" on screen
|
|
3
|
+
// and the relay's refusal of a second Resend agree.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an earlier Resend still stands. A cancelled queued message loses
|
|
7
|
+
* its queue row; a failed one keeps it as 'failed'; a finished one may have
|
|
8
|
+
* been pruned from the queue, which its answer (linked by source) proves.
|
|
9
|
+
*/
|
|
10
|
+
export function isLiveResend({ hasQueueRow = false, queueStatus = null, answered = false } = {}) {
|
|
11
|
+
if (hasQueueRow) {
|
|
12
|
+
return !['failed', 'cancelled'].includes(String(queueStatus || '').trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
return !!answered;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Original message id → the id of a Resend of it that still stands, from one
|
|
19
|
+
* conversation's messages (DB rows) and queue rows ({ id, status }).
|
|
20
|
+
*/
|
|
21
|
+
export function liveResendsByOriginal(dbMessages = [], queueRows = []) {
|
|
22
|
+
const queueById = new Map((Array.isArray(queueRows) ? queueRows : [])
|
|
23
|
+
.map((row) => [String(row?.id || '').trim(), row]));
|
|
24
|
+
const answeredSources = new Set();
|
|
25
|
+
for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
|
|
26
|
+
if (message?.role !== 'assistant') continue;
|
|
27
|
+
const source = String(message?.source_message_id || '').trim();
|
|
28
|
+
if (source) answeredSources.add(source);
|
|
29
|
+
}
|
|
30
|
+
for (const row of queueById.values()) {
|
|
31
|
+
if (row?.response_message_id) answeredSources.add(String(row.id || '').trim());
|
|
32
|
+
}
|
|
33
|
+
const result = new Map();
|
|
34
|
+
for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
|
|
35
|
+
if (message?.role !== 'user') continue;
|
|
36
|
+
const original = String(message?.resend_of_message_id || '').trim();
|
|
37
|
+
if (!original || result.has(original)) continue;
|
|
38
|
+
const id = String(message?.id || '').trim();
|
|
39
|
+
const queueRow = queueById.get(id);
|
|
40
|
+
if (isLiveResend({ hasQueueRow: !!queueRow, queueStatus: queueRow?.status, answered: answeredSources.has(id) })) {
|
|
41
|
+
result.set(original, id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
// persistent Claude CLI process alive, shared so the settings slider and the
|
|
3
3
|
// worker enforcing it agree on bounds and formatting.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The default is 4 hours: long enough for real long-running work to finish,
|
|
6
|
+
// short enough that a forgotten shell or agent does not keep its runtime
|
|
7
|
+
// alive indefinitely. 0 (chosen explicitly) means no limit: a process holding
|
|
8
|
+
// live tasks stays up until the tasks finish, the user stops them from the
|
|
9
|
+
// composer panel, or the worker/relay goes down. Mirrors
|
|
10
|
+
// shared/turn-ceiling.mjs.
|
|
11
|
+
//
|
|
12
|
+
// The browser mirrors the default in
|
|
13
|
+
// server/public/app/background-task-timeout-options.mjs (only server/public is
|
|
14
|
+
// served), and the slider's static markup in server/public/index.html starts
|
|
15
|
+
// at it; unit tests pin both to this module.
|
|
8
16
|
|
|
9
17
|
export const BACKGROUND_TASK_TIMEOUT_MIN_MINUTES = 0;
|
|
10
18
|
export const BACKGROUND_TASK_TIMEOUT_MAX_MINUTES = 600;
|
|
11
19
|
export const BACKGROUND_TASK_TIMEOUT_STEP_MINUTES = 5;
|
|
12
|
-
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES =
|
|
20
|
+
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
|
|
13
21
|
|
|
14
22
|
/** 0 disables the timeout entirely (unlimited). */
|
|
15
23
|
export function normalizeBackgroundTaskTimeoutMinutes(value, fallback = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES) {
|
|
@@ -63,7 +63,10 @@ export function createControlPoller({
|
|
|
63
63
|
|
|
64
64
|
dbg('abort_turn control received', control.id);
|
|
65
65
|
try {
|
|
66
|
-
|
|
66
|
+
// The control names the row the user stopped. A runner that owns several
|
|
67
|
+
// rows at once (a turn plus the messages steered into it) needs it to
|
|
68
|
+
// tell the stopped row from the ones it must settle itself.
|
|
69
|
+
await onAbortTurn(control);
|
|
67
70
|
await api('POST', `/api/control/${encodeURIComponent(control.id)}/result`, {
|
|
68
71
|
ok: true,
|
|
69
72
|
note: abortAckNote,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Whether a choice question reads as "pick several" — for providers whose
|
|
2
|
+
// ask-user tool has no multi-select flag (Copilot's `ask_user`; Claude's
|
|
3
|
+
// AskUserQuestion carries `multiSelect: true` itself). The card then offers
|
|
4
|
+
// checkmarks instead of one-shot buttons and answers with the selected labels
|
|
5
|
+
// joined by ", ". Conservative on purpose: an ordinary "which one?" keeps its
|
|
6
|
+
// buttons, because a wrongly multi-select card costs the user a second click
|
|
7
|
+
// while a wrongly single-select one forces them into the free-text field.
|
|
8
|
+
export function looksLikeMultiSelectQuestion(questionText, choices = []) {
|
|
9
|
+
if (!Array.isArray(choices) || choices.length < 2) return false;
|
|
10
|
+
const text = String(questionText || '');
|
|
11
|
+
return /\ball that apply\b/i.test(text)
|
|
12
|
+
|| /\bmulti-?select\b/i.test(text)
|
|
13
|
+
|| /\b(select|choose|pick|check|tick|enable|turn on)\b[^.!?\n]{0,40}\b(all|multiple|several|any number|one or more|as many|more than one)\b/i.test(text)
|
|
14
|
+
|| /\bmultiple (choices|options|selections|answers)\b/i.test(text)
|
|
15
|
+
|| /\bone or more\b/i.test(text);
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The terminal outcome of a turn the user stopped — what the relay records
|
|
2
|
+
// when a worker acknowledges an abort control. Shared so a worker that dies
|
|
3
|
+
// before its ack lands fails the stopped row the same way instead of letting
|
|
4
|
+
// it be requeued (a requeue re-runs a prompt the user explicitly stopped).
|
|
5
|
+
export function buildRelayStopFailure() {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'turn-aborted',
|
|
8
|
+
error: 'turn-aborted',
|
|
9
|
+
code: 'turn-aborted',
|
|
10
|
+
stableCode: 'relay.turn-aborted',
|
|
11
|
+
message: 'System note: This turn was stopped from the relay UI before completion.',
|
|
12
|
+
guidance: 'Send a new message to continue when you are ready.',
|
|
13
|
+
failedAt: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|