@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -234,6 +234,11 @@ export function createSessionHistoryRefreshService({
234
234
  // replaces those ids, so the rows must go with the messages they annotated
235
235
  // (same lifecycle as subagent_runs). The fallback guards dbs opened before
236
236
  // the table existed.
237
+ // The sub-agent timeline goes with its runs. Null on a database the 0009
238
+ // migration has not reached (no table, nothing to delete).
239
+ const deleteConversationSubagentTimeline = typeof stmts?.deleteConvSubagentTimeline?.run === 'function'
240
+ ? stmts.deleteConvSubagentTimeline
241
+ : null;
237
242
  const deleteConversationWorkflowRuns = typeof stmts?.deleteConvWorkflowRuns?.run === 'function'
238
243
  ? stmts.deleteConvWorkflowRuns
239
244
  : db.prepare(`DELETE FROM workflow_runs WHERE conversation_id = ?`);
@@ -425,6 +430,7 @@ export function createSessionHistoryRefreshService({
425
430
  deleteConversationThoughts.run(conversationId);
426
431
  deleteConversationStreamEvents.run(conversationId);
427
432
  deleteConversationSubagentRuns.run(conversationId);
433
+ deleteConversationSubagentTimeline?.run(conversationId);
428
434
  deleteConversationWorkflowRuns.run(conversationId);
429
435
  });
430
436
 
@@ -441,6 +447,7 @@ export function createSessionHistoryRefreshService({
441
447
  deleteConversationThoughts.run(conversationId);
442
448
  deleteConversationStreamEvents.run(conversationId);
443
449
  deleteConversationSubagentRuns.run(conversationId);
450
+ deleteConversationSubagentTimeline?.run(conversationId);
444
451
  deleteConversationWorkflowRuns.run(conversationId);
445
452
  insertRebuiltMessages(conversationId, messages, attachmentCandidates);
446
453
  const rebuiltMessages = ensureArray(messages);
@@ -14,6 +14,12 @@ import {
14
14
  isOarMcpServerDisabled,
15
15
  } from '../mcp/oar-mcp-server.mjs';
16
16
  import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
17
+ import {
18
+ describeSpawnEnvironment,
19
+ envPathKey,
20
+ readEnvValue,
21
+ windowsCmdPath,
22
+ } from '../../shared/windows-system-tools.mjs';
17
23
 
18
24
  export { isOarMcpServerDisabled };
19
25
 
@@ -43,6 +49,87 @@ function normalizeText(value) {
43
49
  return text || null;
44
50
  }
45
51
 
52
+ // ─── Which Node runs a worker ────────────────────────────────────────────────
53
+ //
54
+ // The relay's own Node by default: its absolute path works whatever PATH the
55
+ // relay was started with. A relay started by the Windows boot task found no
56
+ // `node` on that task's PATH, and every worker launch failed without a trace.
57
+ // COPILOT_WEB_RELAY_NODE still overrides it.
58
+
59
+ export function resolveWorkerNodeExecutable(env = {}, { execPath = process.execPath } = {}) {
60
+ return normalizeText(env?.COPILOT_WEB_RELAY_NODE) || execPath;
61
+ }
62
+
63
+ function pathImplFor(platform) {
64
+ return platform === 'win32' ? path.win32 : path.posix;
65
+ }
66
+
67
+ function isPathLike(value) {
68
+ return /[\\/]/.test(String(value || ''));
69
+ }
70
+
71
+ /** The folder of the worker's Node when it is a path, else null. */
72
+ export function resolveWorkerNodeDirectory(env = {}, { platform = process.platform, execPath = process.execPath } = {}) {
73
+ const executable = resolveWorkerNodeExecutable(env, { execPath });
74
+ const pathImpl = pathImplFor(platform);
75
+ const candidate = isPathLike(executable) ? executable : execPath;
76
+ if (!isPathLike(candidate)) return null;
77
+ return pathImpl.dirname(candidate);
78
+ }
79
+
80
+ /**
81
+ * The environment with the worker's Node folder at the END of PATH when it is
82
+ * not on it yet. The CLIs a worker starts, and the commands its agent runs,
83
+ * may call a bare `node`; appending only matters when PATH has none, and never
84
+ * changes which one a PATH that has one picks.
85
+ */
86
+ export function appendNodeDirectoryToPath(env = {}, nodeDirectory, { platform = process.platform } = {}) {
87
+ const dir = normalizeText(nodeDirectory);
88
+ if (!dir) return env;
89
+ const pathImpl = pathImplFor(platform);
90
+ const key = envPathKey(env, platform);
91
+ const current = String(env?.[key] || '');
92
+ const comparable = (entry) => {
93
+ const trimmed = String(entry || '').trim().replace(/[\\/]+$/, '');
94
+ return platform === 'win32' ? trimmed.toLowerCase() : trimmed;
95
+ };
96
+ const wanted = comparable(dir);
97
+ if (current.split(pathImpl.delimiter).some((entry) => comparable(entry) === wanted)) return env;
98
+ const separator = current && !current.endsWith(pathImpl.delimiter) ? pathImpl.delimiter : '';
99
+ return { ...env, [key]: `${current}${separator}${dir}` };
100
+ }
101
+
102
+ /**
103
+ * Where `command` resolves to with this environment's PATH (and PATHEXT on
104
+ * Windows), or null. A command with a folder part must be a file.
105
+ */
106
+ export function locateExecutable(command, {
107
+ env = {},
108
+ platform = process.platform,
109
+ fsImpl = fs,
110
+ } = {}) {
111
+ const name = String(command || '').trim();
112
+ if (!name) return null;
113
+ const pathImpl = pathImplFor(platform);
114
+ const isFile = (candidate) => {
115
+ try { return fsImpl.statSync(candidate).isFile(); } catch { return false; }
116
+ };
117
+ const extensions = platform === 'win32' && !pathImpl.extname(name)
118
+ ? (readEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').map((ext) => ext.trim()).filter(Boolean)
119
+ : [''];
120
+ const names = extensions.map((ext) => `${name}${ext}`);
121
+ if (extensions[0] !== '') names.push(name);
122
+ if (isPathLike(name)) return names.find(isFile) || null;
123
+ const dirs = readEnvValue(env, 'PATH').split(pathImpl.delimiter).map((dir) => dir.trim()).filter(Boolean);
124
+ for (const dir of dirs) {
125
+ for (const candidate of names) {
126
+ const full = pathImpl.join(dir, candidate);
127
+ if (isFile(full)) return full;
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+
46
133
  export function resolveOpenAIWireApi(model = '') {
47
134
  const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
48
135
  if (
@@ -352,7 +439,7 @@ function resolveNodeWorkerDescriptor(env = {}) {
352
439
  function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs = []) {
353
440
  const nodeWorker = resolveNodeWorkerDescriptor(env);
354
441
  if (nodeWorker) {
355
- const nodeExecutable = normalizeText(env?.COPILOT_WEB_RELAY_NODE) || 'node';
442
+ const nodeExecutable = resolveWorkerNodeExecutable(env);
356
443
  return `${shellQuote(nodeExecutable)} ${shellQuote(nodeWorker.scriptPath)} --session-id ${shellQuote(targetSessionId)}`;
357
444
  }
358
445
  const cliExecutable = normalizeText(env?.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
@@ -689,7 +776,16 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
689
776
  if (!value) continue;
690
777
  exports.push(`${key}=${shellQuote(value)}`);
691
778
  }
692
- const prefix = exports.length ? `${exports.map((entry) => `export ${entry};`).join(' ')} ` : '';
779
+ // `sh -lc` builds PATH from the login profile, not from the relay's; the
780
+ // relay's Node folder goes at its end when it is not on it (see
781
+ // appendNodeDirectoryToPath).
782
+ const statements = exports.map((entry) => `export ${entry};`);
783
+ const nodeDirectory = resolveWorkerNodeDirectory(launchEnv);
784
+ if (nodeDirectory) {
785
+ const quotedDir = shellQuote(nodeDirectory);
786
+ statements.push(`case ":$PATH:" in *:${quotedDir}:*) ;; *) PATH="$PATH:"${quotedDir};; esac; export PATH;`);
787
+ }
788
+ const prefix = statements.length ? `${statements.join(' ')} ` : '';
693
789
  const secretPrefix = normalizedSecretEnvFilePath
694
790
  ? `. ${shellQuote(normalizedSecretEnvFilePath)} || exit $?; rm -f ${shellQuote(normalizedSecretEnvFilePath)}; rmdir ${shellQuote(path.dirname(normalizedSecretEnvFilePath))}; `
695
791
  : '';
@@ -714,6 +810,43 @@ function sleep(ms) {
714
810
  return new Promise((resolve) => setTimeout(resolve, ms));
715
811
  }
716
812
 
813
+ /** How a spawned child ended, filled in as it happens. */
814
+ function watchChildExit(child) {
815
+ const state = { tracked: typeof child?.on === 'function', exited: false, code: null, signal: null, error: null };
816
+ if (!state.tracked) return state;
817
+ try {
818
+ // A listener also keeps a failed spawn's 'error' from going unhandled.
819
+ child.on('error', (error) => { state.error = error; });
820
+ child.on('exit', (code, signal) => {
821
+ state.exited = true;
822
+ state.code = code;
823
+ state.signal = signal;
824
+ });
825
+ } catch {
826
+ state.tracked = false;
827
+ }
828
+ return state;
829
+ }
830
+
831
+ /**
832
+ * Why `cmd /c start <program>` did not start the worker, or null when nothing
833
+ * says it failed. Exported for the tests.
834
+ */
835
+ export function describeWindowsStartFailure(state, { spawnCommand = 'cmd.exe', program = 'node' } = {}) {
836
+ if (!state?.tracked) return null;
837
+ if (state.error) {
838
+ return `worker-spawn-failed: could not run ${spawnCommand} (${state.error.code || state.error.message || 'error'})`;
839
+ }
840
+ if (!state.exited) {
841
+ return `worker-spawn-failed: Windows could not start ${program} (start did not return; it may be showing an error dialog)`;
842
+ }
843
+ if (state.code !== 0) {
844
+ const how = state.code === null || state.code === undefined ? `signal ${state.signal || 'unknown'}` : `exit code ${state.code}`;
845
+ return `worker-spawn-failed: Windows could not start ${program} (start ended with ${how})`;
846
+ }
847
+ return null;
848
+ }
849
+
717
850
  function buildWorkerLaunchEnv({ processCwd, workspaceRoot, env = process.env } = {}) {
718
851
  const launchProcessCwd = String(processCwd || '').trim();
719
852
  const launchWorkspaceRoot = String(workspaceRoot || '').trim() || launchProcessCwd;
@@ -750,6 +883,9 @@ export async function launchSessionCli({
750
883
  tmuxPollDelayMs = 200,
751
884
  detachedPollAttempts = 10,
752
885
  detachedPollDelayMs = 200,
886
+ // win32: how much longer a `start` still running after the polls gets to
887
+ // return before the launch counts as failed (a busy boot can be slow).
888
+ windowsStartGraceMs = 3_000,
753
889
  allowProcessReuse = true,
754
890
  createSecretEnvFileImpl = createWorkerSecretEnvFile,
755
891
  // Extension engine: writes the session's OAR MCP config, returns its path
@@ -761,6 +897,9 @@ export async function launchSessionCli({
761
897
  // Told when the process list could not be read (the error); the launch
762
898
  // goes on without it.
763
899
  onProcessListError = null,
900
+ // Where a launch that went wrong explains itself (one line per call).
901
+ log = null,
902
+ locateExecutableImpl = locateExecutable,
764
903
  } = {}) {
765
904
  const target = String(targetSessionId || '').trim();
766
905
  if (!target) throw new Error('missing-target-session-id');
@@ -809,9 +948,13 @@ export async function launchSessionCli({
809
948
  env,
810
949
  });
811
950
  const launchSessionEnv = {
812
- ...launchEnv,
951
+ ...appendNodeDirectoryToPath(launchEnv, resolveWorkerNodeDirectory(launchEnv, { platform }), { platform }),
813
952
  SESSION_ID: target,
814
953
  };
954
+ const explain = (message) => {
955
+ try { log?.(`worker launcher: session=${target.slice(0, 8)} ${message}`); } catch {}
956
+ };
957
+ const spawnEnvironmentText = () => describeSpawnEnvironment(launchSessionEnv, platform);
815
958
  // Set per launch below, never inherited: a relay started from inside a
816
959
  // worker session would otherwise hand its workers that session's log.
817
960
  delete launchSessionEnv[WORKER_LOG_FILE_ENV];
@@ -824,6 +967,17 @@ export async function launchSessionCli({
824
967
  // session, so its prompt context need not teach the HTTP fallback.
825
968
  if (extensionCliArgs.length) launchSessionEnv.OAR_MCP_SERVER_ATTACHED = '1';
826
969
 
970
+ // A Node that is not there fails loudly here instead of quietly later (on
971
+ // Windows, `start` shows its error in a dialog no one may be able to see).
972
+ if (resolveNodeWorkerDescriptor(launchSessionEnv)) {
973
+ const nodeExecutable = resolveWorkerNodeExecutable(launchSessionEnv);
974
+ if (!locateExecutableImpl(nodeExecutable, { env: launchSessionEnv, platform })) {
975
+ const overridden = Boolean(normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_NODE));
976
+ explain(`cannot start: Node "${nodeExecutable}"${overridden ? ' (COPILOT_WEB_RELAY_NODE)' : ''} was not found; ${spawnEnvironmentText()}`);
977
+ throw new Error(`worker-node-not-found: ${nodeExecutable}${overridden ? ' (set by COPILOT_WEB_RELAY_NODE)' : ''}`);
978
+ }
979
+ }
980
+
827
981
  if (isTmuxAvailable({ platform, execFileSyncImpl })) {
828
982
  const sessionName = normalizeTmuxSessionName(target);
829
983
  const tmuxEnv = { ...launchSessionEnv };
@@ -897,14 +1051,20 @@ export async function launchSessionCli({
897
1051
  }
898
1052
 
899
1053
  const nodeWorker = resolveNodeWorkerDescriptor(launchSessionEnv);
900
- const nodeWorkerExecutable = normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_NODE) || 'node';
1054
+ const nodeWorkerExecutable = resolveWorkerNodeExecutable(launchSessionEnv);
901
1055
  const posixCliExecutable = normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
902
1056
  || normalizeText(launchSessionEnv.COPILOT_CLI_EXECUTABLE)
903
1057
  || normalizeText(launchSessionEnv.COPILOT_CLI_PATH)
904
1058
  || 'copilot';
1059
+ // win32: Node quotes every argument that holds a space or a tab, so
1060
+ // `start "<title>" "C:\Program Files\nodejs\node.exe" ...` reaches cmd.exe
1061
+ // with the title and the path each in one piece; `/s` strips nothing here
1062
+ // because the line after `/c` starts with `start`, not a quote. The title
1063
+ // always holds a space, so `start` never mistakes the program for it.
905
1064
  const spawnCommand = platform === 'win32'
906
- ? (launchSessionEnv.ComSpec || process.env.ComSpec || 'cmd.exe')
1065
+ ? windowsCmdPath({ env: launchSessionEnv })
907
1066
  : (nodeWorker ? nodeWorkerExecutable : posixCliExecutable);
1067
+ const startedProgram = nodeWorker ? nodeWorkerExecutable : (platform === 'win32' ? 'gh' : posixCliExecutable);
908
1068
  const spawnArgs = platform === 'win32'
909
1069
  ? (nodeWorker
910
1070
  ? [
@@ -964,6 +1124,7 @@ export async function launchSessionCli({
964
1124
  stdio: detachedStdio,
965
1125
  windowsHide: platform !== 'win32',
966
1126
  });
1127
+ const childState = watchChildExit(child);
967
1128
  child.unref?.();
968
1129
  if (Array.isArray(detachedStdio) && typeof detachedStdio[1] === 'number') {
969
1130
  // The child inherited the descriptor; the parent's copy must not leak.
@@ -984,7 +1145,24 @@ export async function launchSessionCli({
984
1145
  child,
985
1146
  };
986
1147
  }
1148
+ if (childState.error || (childState.exited && childState.code !== 0)) break;
1149
+ }
1150
+ const graceUntil = Date.now() + Math.max(0, Number(windowsStartGraceMs) || 0);
1151
+ while (childState.tracked && !childState.exited && !childState.error && Date.now() < graceUntil) {
1152
+ await sleep(50);
1153
+ }
1154
+ // `cmd /c start` returns at once when the program started. Still running,
1155
+ // or ended with an error, means it did not: name the program, and log the
1156
+ // command and the PATH it was looked up on.
1157
+ const failure = describeWindowsStartFailure(childState, { spawnCommand, program: startedProgram });
1158
+ if (failure) {
1159
+ if (childState.tracked && !childState.exited) {
1160
+ try { child.kill?.(); } catch {}
1161
+ }
1162
+ explain(`${failure}; command: ${spawnCommand} ${spawnArgs.join(' ')}; ${spawnEnvironmentText()}`);
1163
+ throw new Error(failure);
987
1164
  }
1165
+ explain(`no worker process seen after ${attempts} check${attempts === 1 ? '' : 's'}; it may still report in. program: ${startedProgram}; ${spawnEnvironmentText()}`);
988
1166
  return {
989
1167
  pid: null,
990
1168
  reused: false,
@@ -994,7 +1172,13 @@ export async function launchSessionCli({
994
1172
  };
995
1173
  }
996
1174
  const pid = parsePositiveInt(child?.pid);
997
- if (!pid) throw new Error('worker-spawn-unhealthy:missing-pid');
1175
+ if (!pid) {
1176
+ // A failed spawn reports its error on the next tick.
1177
+ await new Promise((resolve) => setImmediate(resolve));
1178
+ const reason = childState.error?.code || childState.error?.message || '';
1179
+ explain(`could not start ${spawnCommand}${reason ? ` (${reason})` : ''}; ${spawnEnvironmentText()}`);
1180
+ throw new Error(`worker-spawn-unhealthy:missing-pid${reason ? ` (${spawnCommand}: ${reason})` : ''}`);
1181
+ }
998
1182
  return {
999
1183
  pid,
1000
1184
  reused: false,
@@ -3,6 +3,7 @@
3
3
  import { execFile, execFileSync } from 'child_process';
4
4
 
5
5
  import { collectProcessDescendants, isChildProcessOf } from '../../shared/worker-runtime/process-tree.mjs';
6
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
6
7
 
7
8
  function normalizeSessionId(value) {
8
9
  const text = String(value || '').trim();
@@ -319,8 +320,9 @@ export function createSessionWorkerProcessInspector({
319
320
 
320
321
  function getWindowsProcessSnapshot() {
321
322
  if (platform !== 'win32') return [];
322
- const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
323
- stdio: ['ignore', 'pipe', 'ignore'],
323
+ const output = execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
324
+ // stderr is kept (not shown): a failed read reports it.
325
+ stdio: ['ignore', 'pipe', 'pipe'],
324
326
  windowsHide: true,
325
327
  maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER,
326
328
  });
@@ -374,11 +376,13 @@ export function createSessionWorkerProcessInspector({
374
376
  if (platform !== 'win32') return Promise.resolve([]);
375
377
  return new Promise((resolve, reject) => {
376
378
  execFileImpl(
377
- 'powershell.exe',
379
+ windowsSystemToolPath('powershell.exe'),
378
380
  ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
379
381
  { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
380
- (error, stdout) => {
382
+ (error, stdout, stderr) => {
381
383
  if (error) {
384
+ // A failed read reports what PowerShell said.
385
+ if (stderr && error.stderr === undefined) error.stderr = stderr;
382
386
  reject(error);
383
387
  return;
384
388
  }
@@ -541,7 +545,7 @@ export function createSessionWorkerProcessInspector({
541
545
  function stopWindowsPids(pids) {
542
546
  const ids = uniquePids(pids);
543
547
  if (!ids.length) return [];
544
- execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
548
+ execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
545
549
  stdio: ['ignore', 'ignore', 'ignore'],
546
550
  });
547
551
  return ids;
@@ -553,7 +557,7 @@ export function createSessionWorkerProcessInspector({
553
557
  if (!ids.length) return Promise.resolve([]);
554
558
  return new Promise((resolve, reject) => {
555
559
  execFileImpl(
556
- 'powershell.exe',
560
+ windowsSystemToolPath('powershell.exe'),
557
561
  ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)],
558
562
  { windowsHide: true, timeout: WINDOWS_STOP_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
559
563
  (error) => {
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ import { createHmac } from 'node:crypto';
4
+
5
+ // What counts as an open of a shared conversation.
6
+ //
7
+ // The shared page fetches GET /api/shared/:token every 900 ms while it is in
8
+ // the foreground, so a fetch is not an open. Two rules decide:
9
+ //
10
+ // - The page's first fetch after it loads carries `open=1`. That fetch is an
11
+ // open, unless the same viewer opened the same share in the last
12
+ // RELOAD_DEDUPE_MS (a double load, a reload storm).
13
+ // - Any other fetch is an open only when the viewer has not fetched the share
14
+ // for IDLE_MS: a page that slept in the background and came back, or a
15
+ // client that never sends the flag (a script reading the API). Nobody reads
16
+ // the share unlogged by leaving the flag out.
17
+ //
18
+ // A viewer is the share's lineage plus a keyed hash of address and user agent
19
+ // (`viewerKey`), so the rules and the "distinct viewers" count work whether or
20
+ // not the address is stored. When the address is unknown (no socket address),
21
+ // every fetch of that viewer looks alike, which only merges viewers.
22
+ //
23
+ // Last-seen times live in memory. After a relay restart the last logged open
24
+ // stands in for them, so a reader polling across the restart counts once more
25
+ // only when their last open is older than IDLE_MS.
26
+
27
+ export const SHARE_OPEN_IDLE_MS = 30 * 60_000;
28
+ export const SHARE_OPEN_RELOAD_DEDUPE_MS = 10_000;
29
+ const DEFAULT_MAX_TRACKED_VIEWERS = 4_096;
30
+ const USER_AGENT_MAX = 512;
31
+
32
+ function cleanText(value, max) {
33
+ const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
34
+ return text ? text.slice(0, max) : '';
35
+ }
36
+
37
+ /** `::ffff:203.0.113.7` is the IPv4 address it wraps. */
38
+ export function normalizeViewerIp(value) {
39
+ const ip = String(value || '').trim();
40
+ if (!ip) return '';
41
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip);
42
+ return (mapped ? mapped[1] : ip).slice(0, 128);
43
+ }
44
+
45
+ /**
46
+ * The viewer of a public share request: the address as Express derives it
47
+ * through the app's `trust proxy` setting (a forwarded header from an
48
+ * untrusted peer is ignored), the user agent, and the edge's country, read
49
+ * only when the request came through a trusted proxy.
50
+ */
51
+ export function readShareRequestViewer(req) {
52
+ const ip = normalizeViewerIp(req?.ip || req?.socket?.remoteAddress || '');
53
+ const userAgent = cleanText(req?.headers?.['user-agent'], USER_AGENT_MAX);
54
+ let country = '';
55
+ const trust = typeof req?.app?.get === 'function' ? req.app.get('trust proxy fn') : null;
56
+ const peer = String(req?.socket?.remoteAddress || '').trim();
57
+ if (typeof trust === 'function' && peer && trust(peer, 0)) {
58
+ const header = String(req?.headers?.['cf-ipcountry'] || '').trim().toUpperCase();
59
+ if (/^[A-Z0-9]{2}$/.test(header)) country = header;
60
+ }
61
+ return { ip, userAgent, country };
62
+ }
63
+
64
+ export function createShareAccessLog({
65
+ repository,
66
+ now = () => Date.now(),
67
+ idleMs = SHARE_OPEN_IDLE_MS,
68
+ reloadDedupeMs = SHARE_OPEN_RELOAD_DEDUPE_MS,
69
+ maxTrackedViewers = DEFAULT_MAX_TRACKED_VIEWERS,
70
+ } = {}) {
71
+ // `${lineageId}:${viewerKey}` -> { seenAt, openedAt } (epoch ms)
72
+ const viewers = new Map();
73
+
74
+ function viewerKeyFor(ip, userAgent) {
75
+ return createHmac('sha256', repository.readViewerKeySecret())
76
+ .update(`${ip || ''}\n${userAgent || ''}`)
77
+ .digest('hex')
78
+ .slice(0, 32);
79
+ }
80
+
81
+ function prune(at) {
82
+ for (const [key, entry] of viewers) {
83
+ if (at - entry.seenAt > idleMs) viewers.delete(key);
84
+ }
85
+ if (viewers.size <= maxTrackedViewers) return;
86
+ const oldest = [...viewers.entries()].sort((a, b) => a[1].seenAt - b[1].seenAt);
87
+ for (const [key] of oldest.slice(0, viewers.size - maxTrackedViewers)) viewers.delete(key);
88
+ }
89
+
90
+ /**
91
+ * Called for every successful read of a share. Logs an open when the rules
92
+ * above say so; `storeIp` false keeps the address out of the row.
93
+ */
94
+ function noteFetch({ share, viewer, opened = false, storeIp = true }) {
95
+ const at = Number(now());
96
+ const viewerKey = viewerKeyFor(viewer?.ip, viewer?.userAgent);
97
+ const key = `${share.lineageId}:${viewerKey}`;
98
+ let entry = viewers.get(key);
99
+ if (!entry) {
100
+ const lastOpen = Date.parse(repository.lastOpenAt(share.lineageId, viewerKey) || '');
101
+ if (Number.isFinite(lastOpen)) entry = { seenAt: lastOpen, openedAt: lastOpen };
102
+ }
103
+ const isOpen = !entry
104
+ || (opened ? at - entry.openedAt > reloadDedupeMs : at - entry.seenAt > idleMs);
105
+ let recorded = false;
106
+ if (isOpen) {
107
+ recorded = repository.recordOpen({
108
+ lineageId: share.lineageId,
109
+ conversationId: share.conversationId,
110
+ openedAt: new Date(at).toISOString(),
111
+ ip: storeIp ? viewer?.ip || null : null,
112
+ userAgent: viewer?.userAgent || null,
113
+ country: viewer?.country || null,
114
+ viewerKey,
115
+ });
116
+ }
117
+ viewers.set(key, { seenAt: at, openedAt: isOpen ? at : entry.openedAt });
118
+ prune(at);
119
+ return { opened: isOpen, recorded, viewerKey };
120
+ }
121
+
122
+ return { noteFetch, viewerKeyFor };
123
+ }