@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
@@ -112,6 +112,7 @@ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT, STEER_TURN_FAILED_TEXT } from '.
112
112
  import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
113
113
  import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
114
114
  import { withSteerNote } from '../../shared/steer-note.mjs';
115
+ import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
115
116
  import { isTransientRelayError } from '../../shared/worker-runtime/relay-errors.mjs';
116
117
  import {
117
118
  createTurnLiveness,
@@ -460,6 +461,10 @@ export function createCopilotSdkSessionRunner({
460
461
  // heartbeat snapshot, and a 10 s wait would often outlast the window in
461
462
  // which the runtime still holds the message (live check 2026-09-25).
462
463
  onCancellableChange = () => {},
464
+ // The relay turned this worker's newest task set away (`stale`): the
465
+ // worker asks for a heartbeat, on which the relay learns which process
466
+ // holds the session and asks for the set again.
467
+ onBackgroundTaskSetStale = () => {},
463
468
  dbg = () => {},
464
469
  } = {}) {
465
470
  let client = null;
@@ -627,7 +632,15 @@ export function createCopilotSdkSessionRunner({
627
632
  let taskRefreshQueued = 0;
628
633
  let lastTaskRefreshAt = 0;
629
634
  let taskPublishTimer = null;
635
+ // The last set the relay took, as JSON, and the one on its way to it.
630
636
  let lastPublishedTasksSignature = '';
637
+ let inFlightTasksSignature = null;
638
+ let taskPublishSeq = 0;
639
+ // Every post of the set carries its place in this worker's order
640
+ // (`epoch` tells this process from one before it): posts can overtake
641
+ // each other on the way, and the relay applies the newer one only.
642
+ const taskSetEpoch = Date.now();
643
+ let taskSetStaleNotedAt = 0;
631
644
  // Cap expiry is bounded: a task whose cancel the runtime refuses (or that
632
645
  // keeps failing) this many times is abandoned — dropped from the live set so
633
646
  // it stops pinning the runtime — rather than held forever.
@@ -1569,27 +1582,77 @@ export function createCopilotSdkSessionRunner({
1569
1582
  * semantics, the same route the Claude worker publishes on). Set changes
1570
1583
  * publish at once; enrichment-only changes (a tool call, tokens) ride a
1571
1584
  * trailing throttle. Advisory: a failed post never disturbs events.
1585
+ *
1586
+ * A set equal to the last one the relay TOOK (or to the one on its way)
1587
+ * is not sent again: the signature is recorded when the post succeeds, so
1588
+ * a failed post is retried by the next publish instead of being
1589
+ * remembered as sent.
1590
+ * `resync`: the relay asked for the set again on a heartbeat (it
1591
+ * restarted, or a post was lost); that goes out whatever the signature
1592
+ * says, flagged. Returns the post.
1572
1593
  */
1573
- function publishBackgroundTasks({ immediate = false } = {}) {
1594
+ function publishBackgroundTasks({ immediate = false, resync = false } = {}) {
1574
1595
  const run = () => {
1575
1596
  if (taskPublishTimer) {
1576
1597
  clearTimeout(taskPublishTimer);
1577
1598
  taskPublishTimer = null;
1578
1599
  }
1579
- const tasks = sessionRpc.tasks ? registryCards() : shellTrackerCards();
1600
+ const tasks = currentTaskCards();
1580
1601
  const signature = JSON.stringify(tasks);
1581
- if (signature === lastPublishedTasksSignature) return;
1582
- lastPublishedTasksSignature = signature;
1583
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks })
1584
- .catch((error) => dbg('background task publish failed', error?.message || String(error)));
1602
+ const unchanged = inFlightTasksSignature === null
1603
+ ? signature === lastPublishedTasksSignature
1604
+ : signature === inFlightTasksSignature;
1605
+ if (!resync && unchanged) return Promise.resolve();
1606
+ const seq = ++taskPublishSeq;
1607
+ inFlightTasksSignature = signature;
1608
+ return api('POST', '/api/background-tasks', {
1609
+ conversationId: sdkSessionId,
1610
+ tasks,
1611
+ order: { epoch: taskSetEpoch, seq },
1612
+ ...(resync ? { resync: true } : {}),
1613
+ })
1614
+ .then((answer) => {
1615
+ // Only the newest post says what the relay holds now.
1616
+ if (seq !== taskPublishSeq) return;
1617
+ inFlightTasksSignature = null;
1618
+ if (answer?.stale === true) {
1619
+ // Turned away, not taken: nothing is recorded as sent, and a
1620
+ // heartbeat is asked for (at most one per ten seconds; the
1621
+ // periodic beat repeats the relay's request anyway).
1622
+ dbg('the relay dropped the newest background task set', answer.reason || '');
1623
+ if (Date.now() - taskSetStaleNotedAt < 10_000) return;
1624
+ taskSetStaleNotedAt = Date.now();
1625
+ try { onBackgroundTaskSetStale(); } catch {}
1626
+ return;
1627
+ }
1628
+ lastPublishedTasksSignature = signature;
1629
+ })
1630
+ .catch((error) => {
1631
+ // Not taken: the signature stays at what the relay last took, so
1632
+ // the next publish of this set is sent, not skipped.
1633
+ if (seq === taskPublishSeq) inFlightTasksSignature = null;
1634
+ dbg('background task publish failed', error?.message || String(error));
1635
+ });
1585
1636
  };
1586
- if (immediate || !(taskPublishThrottleMs > 0)) {
1587
- run();
1588
- return;
1589
- }
1590
- if (taskPublishTimer) return;
1637
+ if (resync || immediate || !(taskPublishThrottleMs > 0)) return run();
1638
+ if (taskPublishTimer) return Promise.resolve();
1591
1639
  taskPublishTimer = setTimeout(run, taskPublishThrottleMs);
1592
1640
  taskPublishTimer.unref?.();
1641
+ return Promise.resolve();
1642
+ }
1643
+
1644
+ function currentTaskCards() {
1645
+ return sessionRpc.tasks ? registryCards() : shellTrackerCards();
1646
+ }
1647
+
1648
+ /** The ids of the live background tasks, for the heartbeat's digest. */
1649
+ function backgroundTaskIds() {
1650
+ return currentTaskCards().map((task) => task.taskId);
1651
+ }
1652
+
1653
+ /** The relay asked for the task set again (heartbeat answer). */
1654
+ function resyncBackgroundTasks() {
1655
+ return publishBackgroundTasks({ resync: true });
1593
1656
  }
1594
1657
 
1595
1658
  // ------------------------------------------------------- background tasks --
@@ -4216,7 +4279,8 @@ export function createCopilotSdkSessionRunner({
4216
4279
  // message and drop the request it was working on (burn-in 2026-09-25).
4217
4280
  // Not in a continuation: there the running work is the runtime's own
4218
4281
  // follow-up, not a request of the user's to keep going with.
4219
- const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' ? body : withSteerNote(body));
4282
+ // Nor in an event message the relay wrote: the note speaks as the user.
4283
+ const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' || isRelayEventDelivery(message) ? body : withSteerNote(body));
4220
4284
  // Re-checked AFTER the awaits and before anything is sent or registered.
4221
4285
  // Nothing has been sent yet, so handing the row back to the normal path is
4222
4286
  // free.
@@ -4698,6 +4762,8 @@ export function createCopilotSdkSessionRunner({
4698
4762
  steeringState,
4699
4763
  getSettleFailed,
4700
4764
  acknowledgeSettleFailed,
4765
+ backgroundTaskIds,
4766
+ resyncBackgroundTasks,
4701
4767
  cancelPushedMessage,
4702
4768
  // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
4703
4769
  // subagent lane's Stop tries — the runtime tracks sync agents in the same
@@ -4724,6 +4790,7 @@ export function createCopilotSdkSessionRunner({
4724
4790
  ? new Promise((resolve) => { setTimeout(resolve, taskRefreshMs + 5); }).then(() => taskRefreshChain)
4725
4791
  : taskRefreshChain),
4726
4792
  // Test seams / observability.
4793
+ _publishBackgroundTasks: publishBackgroundTasks,
4727
4794
  _getState: () => ({
4728
4795
  hasClient: !!client,
4729
4796
  hasSession: !!session,
@@ -121,6 +121,7 @@ async function main() {
121
121
  // A message became (or stopped being) cancellable: report it now rather
122
122
  // than on the next 10 s heartbeat (coalesced by the controller).
123
123
  onCancellableChange: () => heartbeat?.requestPulse(),
124
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
124
125
  canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
125
126
  dbg,
126
127
  });
@@ -144,6 +145,11 @@ async function main() {
144
145
  getSteeringState: () => turnRunner.steeringState(),
145
146
  getSettleFailed: () => turnRunner.getSettleFailed(),
146
147
  onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
148
+ // The relay holds the task set in memory only: the digest of the live
149
+ // task ids rides every beat, and a relay that is out of step (it
150
+ // restarted, a post was lost) asks for the set again.
151
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
152
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
147
153
  });
148
154
 
149
155
  const wsLink = createWorkerWebSocketLink({
@@ -209,12 +209,66 @@ export function startCursorRun({
209
209
  };
210
210
  // A tool call is reported on both surfaces (the delta hook and the message
211
211
  // stream) under the same id; either one opens and closes it.
212
+ //
213
+ // Not every tool call is reported. The SDK translates only the tools it has
214
+ // a type for and drops the rest on both surfaces: among them the wait on a
215
+ // background shell (`awaitToolCall`), web fetches and input to a running
216
+ // shell. All a dropped call leaves is the step's `tool-requests-listed`
217
+ // count, sent once the model has finished requesting tools; from there the
218
+ // run waits for them without a single event, for as long as the command it
219
+ // waits on takes. So the calls listed beyond those reported count as one
220
+ // open tool, which closes when the model writes again: the SDK tells
221
+ // nothing about such a call ending, and the next step is the first sign.
222
+ // A step without such calls keeps the model window once its reported
223
+ // tools have ended. Steps are told from the delta hook alone: it carries
224
+ // the count, the reported calls and the model's output in step order.
225
+ const stepCalls = new Set();
226
+ let stepListed = false;
227
+ let unreportedCalls = 0;
228
+ const UNREPORTED_TOOLS = 'unreported-tools';
229
+ const noteCallStarted = (callId) => {
230
+ const key = String(callId || '').trim();
231
+ if (!key || stepCalls.has(key)) return;
232
+ stepCalls.add(key);
233
+ // Reported after the count: it was one of the calls counted as unreported.
234
+ if (stepListed && unreportedCalls > 0 && --unreportedCalls === 0) liveness.end('tool', UNREPORTED_TOOLS);
235
+ };
236
+ const noteToolsListed = (callCount) => {
237
+ stepListed = true;
238
+ unreportedCalls = Math.max(0, (Number(callCount) || 0) - stepCalls.size);
239
+ if (unreportedCalls > 0) liveness.begin('tool', UNREPORTED_TOOLS, 'unreported tool');
240
+ };
241
+ const noteModelWriting = () => {
242
+ if (!stepListed) return;
243
+ stepListed = false;
244
+ unreportedCalls = 0;
245
+ stepCalls.clear();
246
+ liveness.end('tool', UNREPORTED_TOOLS);
247
+ };
212
248
  const observe = (event) => {
213
249
  if (event.source === 'delta') {
214
250
  const update = event.update || {};
215
251
  liveness.traffic(update.type);
216
- if (update.type === 'tool-call-started') liveness.begin('tool', update.callId, update.toolCall?.type);
217
- else if (update.type === 'tool-call-completed') liveness.end('tool', update.callId);
252
+ switch (update.type) {
253
+ case 'tool-call-started':
254
+ noteCallStarted(update.callId);
255
+ liveness.begin('tool', update.callId, update.toolCall?.type);
256
+ break;
257
+ case 'tool-call-completed':
258
+ liveness.end('tool', update.callId);
259
+ break;
260
+ case 'tool-requests-listed':
261
+ noteToolsListed(update.callCount);
262
+ break;
263
+ case 'text-delta':
264
+ case 'thinking-delta':
265
+ case 'thinking-completed':
266
+ case 'partial-tool-call':
267
+ noteModelWriting();
268
+ break;
269
+ default:
270
+ break;
271
+ }
218
272
  return;
219
273
  }
220
274
  const message = event.message || {};
@@ -14,6 +14,14 @@ import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
14
14
  import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
15
15
  import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
16
16
  import { migrateMessagePins } from './migrations/0008-message-pins.mjs';
17
+ import { migrateSubagentTimeline } from './migrations/0009-subagent-timeline.mjs';
18
+ import { migrateSubagentTimelinePruning } from './migrations/0010-subagent-timeline-pruning.mjs';
19
+ import { migrateRelayEventSubscriptions } from './migrations/0011-relay-event-subscriptions.mjs';
20
+ import { migrateCrossRelayEventSubscriptions } from './migrations/0012-cross-relay-event-subscriptions.mjs';
21
+ import { migratePreviews } from './migrations/0013-previews.mjs';
22
+ import { migrateConversationGroups } from './migrations/0014-conversation-groups.mjs';
23
+ import { migrateConversationGroupPosition } from './migrations/0015-conversation-group-position.mjs';
24
+ import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
17
25
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
18
26
 
19
27
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -422,8 +430,8 @@ export const SCHEMA_SQL = `
422
430
  ON workflow_runs(conversation_id);
423
431
 
424
432
  -- Previews published mid-turn, pinned to the transcript. Snapshot JSON, not a
425
- -- registry reference: the registry is in-memory and the card must still
426
- -- render after the preview closes or the relay restarts. Written with the
433
+ -- registry reference: the card must still render after the preview closes
434
+ -- (or after a restart dropped it as no longer valid). Written with the
427
435
  -- queue id (the response id does not exist yet mid-turn) and linked to the
428
436
  -- response at finalize, exactly like relay_activity.
429
437
  CREATE TABLE IF NOT EXISTS preview_cards (
@@ -441,6 +449,24 @@ export const SCHEMA_SQL = `
441
449
  CREATE INDEX IF NOT EXISTS idx_preview_cards_queue
442
450
  ON preview_cards(queue_message_id);
443
451
 
452
+ -- Published previews, restored at boot so their public links survive a
453
+ -- restart. Existing databases get the table from migration 0013 (same
454
+ -- columns; a test keeps the two declarations equal). Times are epoch ms.
455
+ -- Its index comes from the migration only, which skips a previews table of
456
+ -- other columns instead of failing the boot over it.
457
+ CREATE TABLE IF NOT EXISTS previews (
458
+ token TEXT PRIMARY KEY,
459
+ mode TEXT NOT NULL,
460
+ conversation_id TEXT,
461
+ label TEXT NOT NULL,
462
+ target_host TEXT,
463
+ target_port INTEGER,
464
+ root_dir TEXT,
465
+ created_at INTEGER NOT NULL,
466
+ last_hit_at INTEGER,
467
+ host_boot_id TEXT
468
+ );
469
+
444
470
  CREATE TABLE IF NOT EXISTS uploaded_files (
445
471
  sha256 TEXT PRIMARY KEY,
446
472
  original_name TEXT,
@@ -742,6 +768,73 @@ try {
742
768
  } catch (error) {
743
769
  console.warn(`[message-pins] migration 0008 failed; retrying on next boot: ${error?.message || error}`);
744
770
  }
771
+ // Sub-agent viewer: the timeline table and the run header columns. Readers
772
+ // tolerate their absence (runs fall back to the activity, thought and stream
773
+ // rows), so a failure is logged and retried next boot like 0008.
774
+ try {
775
+ migrateSubagentTimeline(db);
776
+ } catch (error) {
777
+ console.warn(`[subagent-timeline] migration 0009 failed; retrying on next boot: ${error?.message || error}`);
778
+ }
779
+ // Sub-agent record retention: the pruned marker and the sweep's index. The
780
+ // sweep checks for the column and does nothing without it, so a failure is
781
+ // logged and retried next boot like 0009.
782
+ try {
783
+ migrateSubagentTimelinePruning(db);
784
+ } catch (error) {
785
+ console.warn(`[subagent-timeline] migration 0010 failed; retrying on next boot: ${error?.message || error}`);
786
+ }
787
+ // Relay event subscriptions: two new tables. The event service checks for
788
+ // them and stays off without them, so a failure is logged and retried next
789
+ // boot like 0010.
790
+ try {
791
+ migrateRelayEventSubscriptions(db);
792
+ } catch (error) {
793
+ console.warn(`[relay-events] migration 0011 failed; retrying on next boot: ${error?.message || error}`);
794
+ }
795
+ // Relay event subscriptions across paired relays: columns on 0011's table and
796
+ // the link table. Without them the event service keeps to this relay's own
797
+ // sessions, so a failure is logged and retried next boot like 0011.
798
+ try {
799
+ migrateCrossRelayEventSubscriptions(db);
800
+ } catch (error) {
801
+ console.warn(`[relay-events] migration 0012 failed; retrying on next boot: ${error?.message || error}`);
802
+ }
803
+ // Persisted previews: one new table. The preview store checks for it and keeps
804
+ // previews in memory only without it, so a failure is logged and retried next
805
+ // boot like 0012.
806
+ try {
807
+ migratePreviews(db);
808
+ } catch (error) {
809
+ console.warn(`[previews] migration 0013 failed; retrying on next boot: ${error?.message || error}`);
810
+ }
811
+
812
+ // Session groups: the group table and the conversations' group column. The
813
+ // group repository checks for them and sees no groups without them, so a
814
+ // failure is logged and retried next boot like 0012.
815
+ try {
816
+ migrateConversationGroups(db);
817
+ } catch (error) {
818
+ console.warn(`[session-groups] migration 0014 failed; retrying on next boot: ${error?.message || error}`);
819
+ }
820
+
821
+ // A hand order inside a session group: one column. The group repository
822
+ // checks for it and keeps the activity order without it, so a failure is
823
+ // logged and retried next boot like 0014.
824
+ try {
825
+ migrateConversationGroupPosition(db);
826
+ } catch (error) {
827
+ console.warn(`[session-groups] migration 0015 failed; retrying on next boot: ${error?.message || error}`);
828
+ }
829
+
830
+ // Share lineages and the share access log: one column, one table. The share
831
+ // repository checks for them; without them a renew starts a fresh lineage and
832
+ // no open is logged, so a failure is logged and retried next boot like 0015.
833
+ try {
834
+ migrateShareAccessLog(db);
835
+ } catch (error) {
836
+ console.warn(`[shares] migration 0016 failed; retrying on next boot: ${error?.message || error}`);
837
+ }
745
838
 
746
839
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
747
840
  if (runtimeSessionColumns.length) {
@@ -12,6 +12,7 @@ import {
12
12
  createTurnStalledError,
13
13
  resolveTurnStallWindows,
14
14
  } from '../../shared/worker-runtime/turn-liveness.mjs';
15
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
15
16
 
16
17
  function whichCommand(command = 'grok') {
17
18
  const value = String(command || 'grok').trim() || 'grok';
@@ -522,7 +523,7 @@ export class AcpClient extends EventEmitter {
522
523
  this.proc.kill();
523
524
  if (process.platform === 'win32' && this.proc.pid) {
524
525
  try {
525
- this.spawnImpl('taskkill', ['/pid', String(this.proc.pid), '/t', '/f'], {
526
+ this.spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(this.proc.pid), '/t', '/f'], {
526
527
  stdio: 'ignore',
527
528
  windowsHide: true,
528
529
  });
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { spawn, spawnSync } from 'node:child_process';
14
14
  import fs from 'node:fs/promises';
15
+ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
15
16
 
16
17
  const DEFAULT_OUTPUT_BYTE_LIMIT = 1024 * 1024;
17
18
  const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024;
@@ -55,9 +56,9 @@ export function createAcpHostServices({
55
56
  timeout: 10_000,
56
57
  stdio: 'ignore',
57
58
  });
58
- cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : 'powershell';
59
+ cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : windowsSystemToolPath('powershell');
59
60
  } catch {
60
- cachedWindowsShell = 'powershell';
61
+ cachedWindowsShell = windowsSystemToolPath('powershell');
61
62
  }
62
63
  return cachedWindowsShell;
63
64
  }
@@ -178,7 +179,7 @@ export function createAcpHostServices({
178
179
  if (platform === 'win32' && entry.proc.pid) {
179
180
  // proc.kill() only reaches the shell; shells leave grandchildren behind.
180
181
  try {
181
- spawnImpl('taskkill', ['/pid', String(entry.proc.pid), '/t', '/f'], {
182
+ spawnImpl(windowsSystemToolPath('taskkill'), ['/pid', String(entry.proc.pid), '/t', '/f'], {
182
183
  stdio: 'ignore',
183
184
  windowsHide: true,
184
185
  });
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: sub-agent timeline.
5
+ *
6
+ * 1. `subagent_timeline` — one row per entry of a sub-agent's record (a
7
+ * thought, a tool call, a tool result, reply text, a child agent, a note),
8
+ * in the order the relay first saw it (`seq`, per run). A writer names each
9
+ * entry with its own `entry_key`; a second write under the same key updates
10
+ * the entry in place (live text, a thought that is still streaming) and
11
+ * keeps its place in the order.
12
+ * 2. Header columns on `subagent_runs`: what the task panel shows (model,
13
+ * type, tokens) and what the viewer needs to know about the run (task id,
14
+ * background, whether the record was cut, who writes the timeline). The
15
+ * panel's own copy lives in memory only and is gone when the task settles.
16
+ *
17
+ * Additive and idempotent. Safe to run on every boot.
18
+ *
19
+ * Usage: node server/migrations/0009-subagent-timeline.mjs [path/to/copilot.db]
20
+ */
21
+
22
+ import { fileURLToPath } from 'url';
23
+ import path from 'path';
24
+ import process from 'process';
25
+ import Database from 'better-sqlite3';
26
+
27
+ function tableExists(db, name) {
28
+ return !!db
29
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
30
+ .get(name);
31
+ }
32
+
33
+ function indexExists(db, name) {
34
+ return !!db
35
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
36
+ .get(name);
37
+ }
38
+
39
+ function columnNames(db, table) {
40
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
41
+ }
42
+
43
+ export const SUBAGENT_RUN_HEADER_COLUMNS = Object.freeze([
44
+ ['model', 'TEXT'],
45
+ ['model_inherited', 'INTEGER'],
46
+ ['subagent_type', 'TEXT'],
47
+ ['task_id', 'TEXT'],
48
+ ['total_tokens', 'INTEGER'],
49
+ ['background', 'INTEGER NOT NULL DEFAULT 0'],
50
+ ['truncated', 'INTEGER NOT NULL DEFAULT 0'],
51
+ ['timeline_owner', 'TEXT'],
52
+ ]);
53
+
54
+ export function migrateSubagentTimeline(db) {
55
+ if (!tableExists(db, 'subagent_runs')) return { applied: false, tablesAdded: 0, columnsAdded: 0, indexesAdded: 0 };
56
+ let tablesAdded = 0;
57
+ let columnsAdded = 0;
58
+ let indexesAdded = 0;
59
+
60
+ const runColumns = columnNames(db, 'subagent_runs');
61
+ for (const [name, type] of SUBAGENT_RUN_HEADER_COLUMNS) {
62
+ if (runColumns.has(name)) continue;
63
+ db.exec(`ALTER TABLE subagent_runs ADD COLUMN ${name} ${type}`);
64
+ columnsAdded += 1;
65
+ }
66
+ if (!indexExists(db, 'idx_subagent_runs_parent')) {
67
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_subagent_runs_parent ON subagent_runs(parent_subagent_id)`);
68
+ indexesAdded += 1;
69
+ }
70
+
71
+ if (!tableExists(db, 'subagent_timeline')) {
72
+ db.exec(`
73
+ CREATE TABLE IF NOT EXISTS subagent_timeline (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ run_id TEXT NOT NULL,
76
+ conversation_id TEXT NOT NULL,
77
+ queue_message_id TEXT,
78
+ entry_key TEXT NOT NULL,
79
+ seq INTEGER NOT NULL,
80
+ kind TEXT NOT NULL,
81
+ text TEXT,
82
+ tool_name TEXT,
83
+ tool_use_id TEXT,
84
+ metadata_json TEXT,
85
+ done INTEGER NOT NULL DEFAULT 1,
86
+ created_at TEXT NOT NULL,
87
+ updated_at TEXT NOT NULL,
88
+ UNIQUE (run_id, entry_key),
89
+ UNIQUE (run_id, seq),
90
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
91
+ )
92
+ `);
93
+ tablesAdded += 1;
94
+ }
95
+ if (!indexExists(db, 'idx_subagent_timeline_conversation')) {
96
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_subagent_timeline_conversation ON subagent_timeline(conversation_id)`);
97
+ indexesAdded += 1;
98
+ }
99
+ return {
100
+ applied: tablesAdded + columnsAdded + indexesAdded > 0,
101
+ tablesAdded,
102
+ columnsAdded,
103
+ indexesAdded,
104
+ };
105
+ }
106
+
107
+ function defaultDbPath() {
108
+ const here = path.dirname(fileURLToPath(import.meta.url));
109
+ return path.resolve(here, '..', 'data', 'copilot.db');
110
+ }
111
+
112
+ export function migrate(dbPath, options = {}) {
113
+ const db = new Database(dbPath, options);
114
+ try {
115
+ return migrateSubagentTimeline(db);
116
+ } finally {
117
+ db.close();
118
+ }
119
+ }
120
+
121
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
122
+ if (isMain) {
123
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
124
+ try {
125
+ const result = migrate(dbPath);
126
+ console.log(`Migration 0009 on ${dbPath}`);
127
+ console.log(result.applied
128
+ ? ` sub-agent timeline: tables added ${result.tablesAdded}, columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
129
+ : ' skipped: nothing to change');
130
+ process.exit(0);
131
+ } catch (err) {
132
+ console.error(`Migration 0009 failed: ${err?.message || err}`);
133
+ process.exit(1);
134
+ }
135
+ }
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: pruning old sub-agent records.
5
+ *
6
+ * After the retention period the relay strips a finished run's tool inputs
7
+ * and tool results (services/subagent-record-retention.mjs). `pruned_at`
8
+ * marks an entry it already stripped, and a partial index over the tool
9
+ * entries not stripped yet, by age, lets the daily sweep go straight to its
10
+ * candidates instead of reading the whole table every day.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot.
13
+ *
14
+ * Usage: node server/migrations/0010-subagent-timeline-pruning.mjs [path/to/copilot.db]
15
+ */
16
+
17
+ import { fileURLToPath } from 'url';
18
+ import path from 'path';
19
+ import process from 'process';
20
+ import Database from 'better-sqlite3';
21
+
22
+ export const PRUNABLE_INDEX_NAME = 'idx_subagent_timeline_prunable';
23
+
24
+ function tableExists(db, name) {
25
+ return !!db
26
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
27
+ .get(name);
28
+ }
29
+
30
+ function indexExists(db, name) {
31
+ return !!db
32
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
33
+ .get(name);
34
+ }
35
+
36
+ function columnNames(db, table) {
37
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
38
+ }
39
+
40
+ export function migrateSubagentTimelinePruning(db) {
41
+ if (!tableExists(db, 'subagent_timeline')) return { applied: false, columnsAdded: 0, indexesAdded: 0 };
42
+ let columnsAdded = 0;
43
+ let indexesAdded = 0;
44
+ if (!columnNames(db, 'subagent_timeline').has('pruned_at')) {
45
+ db.exec(`ALTER TABLE subagent_timeline ADD COLUMN pruned_at TEXT`);
46
+ columnsAdded += 1;
47
+ }
48
+ if (!indexExists(db, PRUNABLE_INDEX_NAME)) {
49
+ // The sweep's WHERE repeats this condition word for word, so SQLite can
50
+ // use the partial index.
51
+ db.exec(`
52
+ CREATE INDEX IF NOT EXISTS ${PRUNABLE_INDEX_NAME}
53
+ ON subagent_timeline(updated_at)
54
+ WHERE pruned_at IS NULL AND kind IN ('tool_call', 'tool_result')
55
+ `);
56
+ indexesAdded += 1;
57
+ }
58
+ return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
59
+ }
60
+
61
+ function defaultDbPath() {
62
+ const here = path.dirname(fileURLToPath(import.meta.url));
63
+ return path.resolve(here, '..', 'data', 'copilot.db');
64
+ }
65
+
66
+ export function migrate(dbPath, options = {}) {
67
+ const db = new Database(dbPath, options);
68
+ try {
69
+ return migrateSubagentTimelinePruning(db);
70
+ } finally {
71
+ db.close();
72
+ }
73
+ }
74
+
75
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
76
+ if (isMain) {
77
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
78
+ try {
79
+ const result = migrate(dbPath);
80
+ console.log(`Migration 0010 on ${dbPath}`);
81
+ console.log(result.applied
82
+ ? ` sub-agent timeline pruning: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
83
+ : ' skipped: nothing to change');
84
+ process.exit(0);
85
+ } catch (err) {
86
+ console.error(`Migration 0010 failed: ${err?.message || err}`);
87
+ process.exit(1);
88
+ }
89
+ }