@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.
Files changed (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. 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
- return `${shellQuote(cliExecutable)} --allow-all --session-id ${shellQuote(targetSessionId)}`;
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 explicitDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_LOG_DIR);
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 = String(targetSessionId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'worker';
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
- ...nextState,
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
- meta.ready = true;
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
- // 0 means no limit — the default: a process holding live tasks stays up until
6
- // the tasks finish, the user stops them from the composer panel, or the
7
- // worker/relay goes down. Mirrors shared/turn-ceiling.mjs.
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 = 0;
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
- await onAbortTurn();
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
+ }