@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -120,6 +120,10 @@ import {
120
120
  probeRuntime,
121
121
  resolveTurnStallWindows,
122
122
  } from '../../shared/worker-runtime/turn-liveness.mjs';
123
+ import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-spool.mjs';
124
+ import { createShellOutputMirror } from './copilot-shell-output-mirror.mjs';
125
+ import { isTransientProgressText, settleForTask, shellExitFromEvent } from './copilot-task-settle.mjs';
126
+ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
123
127
 
124
128
  // How long the runtime may sit with no session activity before the worker
125
129
  // closes it. The worker process itself stays up and reconnects lazily on the
@@ -235,6 +239,8 @@ const STOPPED_ROW_PUBLISH_WAIT_MS = 5_000;
235
239
  * (which carries no payload and fires ~20 times per shell call).
236
240
  */
237
241
  const DEFAULT_TASK_REFRESH_MS = 250;
242
+ /** How often the task output spool drops files of tasks that ended over an hour ago. */
243
+ const SPOOL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
238
244
  /** Trailing throttle for enrichment-only card updates (a live tool call, tokens). */
239
245
  const DEFAULT_TASK_PUBLISH_THROTTLE_MS = 2_000;
240
246
  /**
@@ -465,6 +471,9 @@ export function createCopilotSdkSessionRunner({
465
471
  // worker asks for a heartbeat, on which the relay learns which process
466
472
  // holds the session and asks for the set again.
467
473
  onBackgroundTaskSetStale = () => {},
474
+ // Where attached shells' output is mirrored for the task console
475
+ // (shared/worker-runtime/task-output-spool.mjs). Tests pass their own.
476
+ taskOutputSpool = null,
468
477
  dbg = () => {},
469
478
  } = {}) {
470
479
  let client = null;
@@ -619,6 +628,24 @@ export function createCopilotSdkSessionRunner({
619
628
  // cards; the registry alone decides liveness (the lifecycle pin).
620
629
  let backgroundTasks = new Map();
621
630
  const agentDetails = new Map();
631
+ // A detached shell names its own log (`TaskShellInfo.logPath`); an attached
632
+ // one has only the runtime's recent-output window, mirrored into a spool
633
+ // file here so the task console can read it like any other log.
634
+ const spool = taskOutputSpool || (() => {
635
+ try { return createTaskOutputSpool({ sessionId: sdkSessionId }); } catch (error) {
636
+ dbg('task output spool unavailable', error?.message || String(error));
637
+ return null;
638
+ }
639
+ })();
640
+ const shellOutputMirror = createShellOutputMirror({ spool, dbg });
641
+ // Ended tasks' files are kept about an hour for the relay; swept at start
642
+ // and hourly.
643
+ const sweepSpool = () => {
644
+ try { spool?.sweep(); } catch (error) { dbg('task output spool sweep failed', error?.message || String(error)); }
645
+ };
646
+ sweepSpool();
647
+ const spoolSweepTimer = setInterval(sweepSpool, SPOOL_SWEEP_INTERVAL_MS);
648
+ spoolSweepTimer.unref?.();
622
649
  let taskRefreshTimer = null;
623
650
  let taskRefreshChain = Promise.resolve();
624
651
  // Reads of the registry that failed in a row. While there are some, "no
@@ -678,6 +705,16 @@ export function createCopilotSdkSessionRunner({
678
705
  // The detached shells this session has running. The lifecycle's pin, and the
679
706
  // reason a settled one is worth waiting for.
680
707
  const backgroundShells = createBackgroundShellTracker();
708
+ // How a background task ended, told to the relay (its live set only says
709
+ // the task is gone). A failed post is kept and sent again after the next
710
+ // heartbeat answer.
711
+ const keptTaskSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
712
+ // shellId → the exit code the events reported (a `shell_completed`
713
+ // notification, a `read_bash` result), and the ends already noted
714
+ // (taskId → { body, exitCode, registryStatus }): a detached shell's code
715
+ // usually comes with the `read_bash` after its end, and is posted then.
716
+ const shellExitCodes = new Map();
717
+ const postedSettles = new Map();
681
718
  // When a shell settled and the continuation it should trigger has not opened
682
719
  // yet. Holds the runtime for `continuationGraceMs`; cleared by the
683
720
  // continuation opening, or by the grace expiring on a runtime that decided it
@@ -1530,6 +1567,7 @@ export function createCopilotSdkSessionRunner({
1530
1567
  taskId: shell.shellId,
1531
1568
  taskType: 'local_bash',
1532
1569
  description: shell.description || 'Detached shell',
1570
+ ...(shell.command ? { command: shell.command } : {}),
1533
1571
  startedAt: shell.startedAt || null,
1534
1572
  stoppable: false,
1535
1573
  }));
@@ -1550,9 +1588,11 @@ export function createCopilotSdkSessionRunner({
1550
1588
  taskId: task.id,
1551
1589
  taskType: 'local_bash',
1552
1590
  description: task.description || task.command || 'Background shell',
1591
+ ...shellCommandField(task),
1553
1592
  startedAt,
1554
1593
  stoppable: true,
1555
1594
  ...(task.progressSummary ? { summary: task.progressSummary } : {}),
1595
+ ...shellOutputFields(task),
1556
1596
  });
1557
1597
  continue;
1558
1598
  }
@@ -1572,11 +1612,28 @@ export function createCopilotSdkSessionRunner({
1572
1612
  ...(detail.lastToolCall ? { lastToolCall: detail.lastToolCall } : {}),
1573
1613
  ...(Number.isFinite(detail.totalTokens) && detail.totalTokens > 0 ? { totalTokens: detail.totalTokens } : {}),
1574
1614
  ...(task.progressSummary ? { summary: task.progressSummary } : {}),
1615
+ ...(Number.isInteger(detail.steps) ? { steps: detail.steps } : {}),
1575
1616
  });
1576
1617
  }
1577
1618
  return cards;
1578
1619
  }
1579
1620
 
1621
+ /** A registry shell's command line, bounded (the relay scrubs, then cuts it to 2,000). */
1622
+ function shellCommandField(task) {
1623
+ const command = typeof task?.command === 'string' ? task.command.trim().slice(0, 8000) : '';
1624
+ return command ? { command } : {};
1625
+ }
1626
+
1627
+ /** Where the task console reads a shell's output: its own log, or the spool mirror. */
1628
+ function shellOutputFields(task) {
1629
+ const logPath = String(task.logPath || '').trim();
1630
+ if (logPath) return { outputPath: logPath, outputKind: 'file' };
1631
+ if (spool && shellOutputMirror.has(task.id)) {
1632
+ try { return { outputPath: spool.pathFor(task.id), outputKind: 'spool' }; } catch {}
1633
+ }
1634
+ return {};
1635
+ }
1636
+
1580
1637
  /**
1581
1638
  * Mirror the live background work into the relay's task panel (REPLACE
1582
1639
  * semantics, the same route the Claude worker publishes on). Set changes
@@ -1682,6 +1739,8 @@ export function createCopilotSdkSessionRunner({
1682
1739
  model: String(data.model || '').trim(),
1683
1740
  lastToolCall: '',
1684
1741
  totalTokens: 0,
1742
+ // Tool calls so far: the panel row's step count.
1743
+ steps: 0,
1685
1744
  startedAt: Date.now(),
1686
1745
  });
1687
1746
  scheduleTaskRefresh();
@@ -1706,6 +1765,7 @@ export function createCopilotSdkSessionRunner({
1706
1765
  const detail = agentDetails.get(agentId);
1707
1766
  if (type === 'tool.execution_start') {
1708
1767
  detail.lastToolCall = formatToolActivityText(data.toolName, data.arguments);
1768
+ detail.steps += 1;
1709
1769
  publishBackgroundTasks();
1710
1770
  } else if (type === 'assistant.usage') {
1711
1771
  const used = (Number(data.inputTokens) || 0) + (Number(data.outputTokens) || 0);
@@ -1716,6 +1776,9 @@ export function createCopilotSdkSessionRunner({
1716
1776
  } else if (type === 'subagent.completed' || type === 'subagent.failed') {
1717
1777
  const total = Number(data.totalTokens);
1718
1778
  if (Number.isFinite(total) && total > 0) detail.totalTokens = total;
1779
+ // The runtime's own count wins over the events this worker saw.
1780
+ const toolCalls = Number(data.totalToolCalls);
1781
+ if (Number.isFinite(toolCalls) && toolCalls >= 0) detail.steps = Math.floor(toolCalls);
1719
1782
  scheduleTaskRefresh();
1720
1783
  }
1721
1784
  }
@@ -1758,6 +1821,8 @@ export function createCopilotSdkSessionRunner({
1758
1821
  }
1759
1822
  if (session !== target) return;
1760
1823
  taskRefreshFailures = 0;
1824
+ const listedById = new Map((Array.isArray(listed?.tasks) ? listed.tasks : [])
1825
+ .map((task) => [String(task?.id || '').trim(), task]));
1761
1826
  const next = new Map();
1762
1827
  for (const task of Array.isArray(listed?.tasks) ? listed.tasks : []) {
1763
1828
  const id = String(task?.id || '').trim();
@@ -1773,6 +1838,10 @@ export function createCopilotSdkSessionRunner({
1773
1838
  next.set(id, { ...task, id, status, progressSummary: backgroundTasks.get(id)?.progressSummary || '' });
1774
1839
  }
1775
1840
  }
1841
+ // Tasks that left the live set, with where a shell's output is (before
1842
+ // its mirror ends below).
1843
+ const departed = [...backgroundTasks.values()].filter((task) => !next.has(task.id));
1844
+ const departedOutput = new Map(departed.map((task) => [task.id, task.type === 'shell' ? shellOutputFields(task) : {}]));
1776
1845
  // Progress lines for the live set: the agent's latest intent / last
1777
1846
  // activity, a shell's last output line. One RPC per live task per refresh
1778
1847
  // (refreshes are debounced), best effort.
@@ -1781,26 +1850,69 @@ export function createCopilotSdkSessionRunner({
1781
1850
  try {
1782
1851
  const progress = (await askRuntime('tasks.getProgress', () => target.rpc.tasks.getProgress({ id: task.id })))?.progress || null;
1783
1852
  task.progressSummary = summarizeTaskProgress(progress);
1853
+ mirrorShellProgress(task, progress);
1784
1854
  } catch { /* a card without a progress line */ }
1785
1855
  }
1856
+ // A mirrored shell that just left the live set: its last window, once
1857
+ // (the runtime still tracks a finished task), then the mirror ends.
1858
+ for (const id of shellOutputMirror.ids()) {
1859
+ if (next.has(id)) continue;
1860
+ try {
1861
+ const progress = (await askRuntime('tasks.getProgress', () => target.rpc.tasks.getProgress({ id })))?.progress || null;
1862
+ if (progress?.type === 'shell') shellOutputMirror.mirror(id, progress.recentOutput);
1863
+ } catch { /* the file keeps what it had */ }
1864
+ shellOutputMirror.forget(id);
1865
+ }
1786
1866
  }
1787
1867
  if (session !== target) return;
1788
1868
  const setChanged = next.size !== backgroundTasks.size
1789
1869
  || [...next.keys()].some((id) => !backgroundTasks.has(id));
1790
1870
  backgroundTasks = next;
1871
+ // How they ended: the registry's finished entry (its status, an agent's
1872
+ // result) and a shell's exit code.
1873
+ for (const task of departed) {
1874
+ if (task.type !== 'shell' && task.type !== 'agent') continue;
1875
+ const finished = listedById.get(task.id) || task;
1876
+ const shell = task.type === 'shell';
1877
+ postTaskSettle(task.id, {
1878
+ ...settleForTask(finished, { exitCode: shell ? (shellExitCodes.get(task.id) ?? null) : null }),
1879
+ description: task.description || task.displayName || task.command || (shell ? 'Background shell' : 'Background agent'),
1880
+ ...(shell ? shellCommandField(task) : {}),
1881
+ taskType: shell ? 'local_bash' : 'local_agent',
1882
+ startedAt: Date.parse(String(task.startedAt || '')) || null,
1883
+ ...(task.toolCallId ? { toolUseId: String(task.toolCallId) } : {}),
1884
+ ...(departedOutput.get(task.id) || {}),
1885
+ }, { registryStatus: String(finished.status || '') });
1886
+ }
1791
1887
  publishBackgroundTasks({ immediate: setChanged });
1792
1888
  // The lifecycle poll (every `lifecyclePollMs`) picks the new set up; a
1793
1889
  // re-evaluation here would re-schedule a refresh and spin.
1794
1890
  }
1795
1891
 
1892
+ /**
1893
+ * An attached shell's recent-output window into the spool (a detached one
1894
+ * writes its own log). The refresh cadence is the registry's, unchanged:
1895
+ * the window only stops being thrown away.
1896
+ */
1897
+ function mirrorShellProgress(task, progress) {
1898
+ if (task.type !== 'shell' || progress?.type !== 'shell') return;
1899
+ if (String(task.logPath || '').trim()) return;
1900
+ shellOutputMirror.mirror(task.id, progress.recentOutput);
1901
+ }
1902
+
1796
1903
  function summarizeTaskProgress(progress) {
1797
1904
  if (!progress || typeof progress !== 'object') return '';
1798
1905
  if (progress.type === 'agent') {
1906
+ // A spinner or status line ("⟳ Thinking…") says nothing the row does
1907
+ // not; the newest line with content does, else none.
1799
1908
  const intent = String(progress.latestIntent || '').trim();
1800
- if (intent) return intent.slice(0, 300);
1909
+ if (intent && !isTransientProgressText(intent)) return intent.slice(0, 300);
1801
1910
  const lines = Array.isArray(progress.recentActivity) ? progress.recentActivity : [];
1802
- const last = lines.length ? String(lines[lines.length - 1]?.message || '').trim() : '';
1803
- return last.slice(0, 300);
1911
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1912
+ const line = String(lines[index]?.message || '').trim();
1913
+ if (line && !isTransientProgressText(line)) return line.slice(0, 300);
1914
+ }
1915
+ return '';
1804
1916
  }
1805
1917
  if (progress.type === 'shell') {
1806
1918
  const output = String(progress.recentOutput || '').trim();
@@ -1972,7 +2084,46 @@ export function createCopilotSdkSessionRunner({
1972
2084
  dbg(`copilot session rpc surfaces: queue=${sessionRpc.queue} interruptMainTurn=${sessionRpc.interruptMainTurn} tasks=${sessionRpc.tasks}`);
1973
2085
  }
1974
2086
 
2087
+ /** A shell's exit code from an event; a settle already posted without it goes again. */
2088
+ function noteShellExit(event) {
2089
+ let exit = null;
2090
+ try { exit = shellExitFromEvent(event); } catch {}
2091
+ if (!exit) return;
2092
+ shellExitCodes.set(exit.shellId, exit.exitCode);
2093
+ while (shellExitCodes.size > 200) shellExitCodes.delete(shellExitCodes.keys().next().value);
2094
+ const posted = postedSettles.get(exit.shellId);
2095
+ if (!posted || posted.exitCode !== null || posted.body.taskType !== 'local_bash') return;
2096
+ postTaskSettle(exit.shellId, {
2097
+ ...posted.body,
2098
+ ...settleForTask({ type: 'shell', status: posted.registryStatus || '' }, { exitCode: exit.exitCode }),
2099
+ }, { registryStatus: posted.registryStatus });
2100
+ }
2101
+
2102
+ /**
2103
+ * Tell the relay how a background task ended (advisory, kept on failure).
2104
+ * An end nothing has told yet (`unknown`) is only noted: the relay already
2105
+ * calls a task that left its set without a word unknown, and an exit code
2106
+ * that comes later is posted then.
2107
+ */
2108
+ function postTaskSettle(taskId, { status, exitCode, summary, ...rest }, { registryStatus } = {}) {
2109
+ const id = String(taskId || '').trim();
2110
+ if (!id) return;
2111
+ const body = {
2112
+ conversationId: sdkSessionId,
2113
+ taskId: id,
2114
+ status,
2115
+ summary: summary || null,
2116
+ ...rest,
2117
+ ...(Number.isInteger(exitCode) ? { exitCode } : {}),
2118
+ };
2119
+ postedSettles.set(id, { body, exitCode: Number.isInteger(exitCode) ? exitCode : null, registryStatus: registryStatus || '' });
2120
+ while (postedSettles.size > 200) postedSettles.delete(postedSettles.keys().next().value);
2121
+ if (status === 'unknown') return;
2122
+ void keptTaskSettles.post('/api/background-task-settled', body);
2123
+ }
2124
+
1975
2125
  function observeBackgroundShells(event) {
2126
+ noteShellExit(event);
1976
2127
  let changed;
1977
2128
  try {
1978
2129
  changed = backgroundShells.observe(event);
@@ -1986,6 +2137,18 @@ export function createCopilotSdkSessionRunner({
1986
2137
  }
1987
2138
  for (const shell of changed.settled) {
1988
2139
  dbg('detached shell settled', shell.shellId);
2140
+ // Without the task registry the tracker's set is the panel's: the end
2141
+ // of a shell it listed is told here (an id it never saw open, as a
2142
+ // foreground shell's result names one, was never a task).
2143
+ if (!sessionRpc.tasks && shell.startedAt) {
2144
+ postTaskSettle(shell.shellId, {
2145
+ ...settleForTask({ type: 'shell', status: '' }, { exitCode: shellExitCodes.get(shell.shellId) ?? null }),
2146
+ description: shell.description || 'Detached shell',
2147
+ ...(shell.command ? { command: shell.command } : {}),
2148
+ taskType: 'local_bash',
2149
+ startedAt: shell.startedAt || null,
2150
+ });
2151
+ }
1989
2152
  const note = describeSettledShell(shell);
1990
2153
  // Only carried when there is no turn to publish into: inside a turn the
1991
2154
  // normalizer already narrates the `read_bash` that reads the output. A
@@ -4741,6 +4904,7 @@ export function createCopilotSdkSessionRunner({
4741
4904
  async function dispose() {
4742
4905
  disposed = true;
4743
4906
  stopLifecycleTimer();
4907
+ clearInterval(spoolSweepTimer);
4744
4908
  if (queueLaneTimer) {
4745
4909
  clearTimeout(queueLaneTimer);
4746
4910
  queueLaneTimer = null;
@@ -4764,6 +4928,8 @@ export function createCopilotSdkSessionRunner({
4764
4928
  acknowledgeSettleFailed,
4765
4929
  backgroundTaskIds,
4766
4930
  resyncBackgroundTasks,
4931
+ // The relay answered a heartbeat: settle notices kept for it go out.
4932
+ flushKeptTaskSettles: () => keptTaskSettles.flush(),
4767
4933
  cancelPushedMessage,
4768
4934
  // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
4769
4935
  // subagent lane's Stop tries — the runtime tracks sync agents in the same
@@ -150,6 +150,8 @@ async function main() {
150
150
  // restarted, a post was lost) asks for the set again.
151
151
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
152
152
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
153
+ // The relay answered: settle notices it missed go out.
154
+ onAnswered: () => turnRunner.flushKeptTaskSettles?.(),
153
155
  });
154
156
 
155
157
  const wsLink = createWorkerWebSocketLink({
@@ -0,0 +1,94 @@
1
+ // An attached Copilot shell keeps its output in the runtime only: the worker
2
+ // sees it as `tasks.getProgress().recentOutput`, a sliding window of the last
3
+ // lines, on every registry refresh. The task console needs a file, so each
4
+ // window's new part is appended to the session's spool
5
+ // (shared/worker-runtime/task-output-spool.mjs).
6
+ //
7
+ // Consecutive windows overlap: the new part is what follows the longest
8
+ // suffix of the previous window that is also a prefix of the new one. With no
9
+ // overlap at all (more output between two refreshes than the window holds)
10
+ // the lines in between are lost; the new window is appended whole after a
11
+ // line break, and the modal says the output is mirrored from a window.
12
+
13
+ /** The runtime's placeholder for a shell that has printed nothing yet. */
14
+ const NO_OUTPUT_PLACEHOLDER = '(no output yet)';
15
+
16
+ /**
17
+ * Length of the longest suffix of `previous` that is a prefix of `next`
18
+ * (prefix function over `next + sep + previous`, linear time: windows are
19
+ * kilobytes and are compared on every refresh).
20
+ */
21
+ export function overlapLength(previous, next) {
22
+ const a = String(next || '');
23
+ const b = String(previous || '');
24
+ if (!a || !b) return 0;
25
+ const n = a.length;
26
+ const fail = new Int32Array(n);
27
+ for (let i = 1, k = 0; i < n; i += 1) {
28
+ while (k > 0 && a[i] !== a[k]) k = fail[k - 1];
29
+ if (a[i] === a[k]) k += 1;
30
+ fail[i] = k;
31
+ }
32
+ let k = 0;
33
+ for (let i = 0; i < b.length; i += 1) {
34
+ while (k > 0 && (k === n || b[i] !== a[k])) k = fail[k - 1];
35
+ if (b[i] === a[k]) k += 1;
36
+ }
37
+ return k;
38
+ }
39
+
40
+ /**
41
+ * What to append when the window moves from `previous` to `next`:
42
+ * `{ text, gap }`, `gap` true when the windows share nothing.
43
+ */
44
+ export function newOutputSuffix(previous, next) {
45
+ const before = String(previous || '');
46
+ const after = String(next || '');
47
+ if (!after || after === before) return { text: '', gap: false };
48
+ if (!before) return { text: after, gap: false };
49
+ if (after.startsWith(before)) return { text: after.slice(before.length), gap: false };
50
+ const overlap = overlapLength(before, after);
51
+ if (overlap > 0) return { text: after.slice(overlap), gap: false };
52
+ return { text: after, gap: true };
53
+ }
54
+
55
+ export function createShellOutputMirror({ spool, dbg = () => {} } = {}) {
56
+ // taskId → { window, endsWithNewline }
57
+ const mirrors = new Map();
58
+
59
+ /** Feed one `recentOutput` window; returns the text appended. */
60
+ function mirror(taskId, recentOutput) {
61
+ const id = String(taskId || '').trim();
62
+ if (!id || !spool) return '';
63
+ const state = mirrors.get(id) || { window: '', endsWithNewline: true };
64
+ mirrors.set(id, state);
65
+ const window = typeof recentOutput === 'string' ? recentOutput : '';
66
+ if (!window || window.trim() === NO_OUTPUT_PLACEHOLDER) return '';
67
+ const { text, gap } = newOutputSuffix(state.window, window);
68
+ state.window = window;
69
+ if (!text) return '';
70
+ const written = gap && !state.endsWithNewline ? `\n${text}` : text;
71
+ const result = spool.append(id, written);
72
+ if (result && result.ok === false) dbg('task output spool append failed', id);
73
+ state.endsWithNewline = written.endsWith('\n');
74
+ return written;
75
+ }
76
+
77
+ /** Whether a window of this task was ever fed (its spool file is named). */
78
+ function has(taskId) {
79
+ return mirrors.has(String(taskId || '').trim());
80
+ }
81
+
82
+ /** The task ended: no more windows; the file stays for the relay. */
83
+ function forget(taskId) {
84
+ const id = String(taskId || '').trim();
85
+ if (!mirrors.delete(id)) return;
86
+ try { spool?.end?.(id); } catch {}
87
+ }
88
+
89
+ function ids() {
90
+ return [...mirrors.keys()];
91
+ }
92
+
93
+ return { mirror, has, forget, ids };
94
+ }
@@ -0,0 +1,96 @@
1
+ // How a Copilot background task ended, for the relay's settle post
2
+ // (`/api/background-task-settled`), with no I/O so it can be tested against
3
+ // the runtime's payloads.
4
+ //
5
+ // What the runtime gives (SDK 1.0.17, generated/rpc.d.ts and
6
+ // generated/session-events.d.ts):
7
+ //
8
+ // - `tasks.list()` keeps a finished task with `status` completed, failed or
9
+ // cancelled (`TaskStatus`); an agent adds `result` (its result text) or
10
+ // `error`. A shell entry (`TaskShellInfo`) carries no exit code.
11
+ // - `system.notification` of kind `shell_completed` (an attached shell) has
12
+ // an optional `exitCode`; `shell_detached_completed` has none, and its
13
+ // text only says to read the shell with `read_bash`.
14
+ // - a `read_bash` (or `bash`) result ends with
15
+ // `<detached command with shellId: 1 completed with exit code 0>` once
16
+ // the shell is done: the detached shell's exit code, when the model reads
17
+ // it.
18
+ //
19
+ // A progress line (`tasks.getProgress`: the agent's latest intent, its last
20
+ // activity line) is how the task is doing, not how it ended: a spinner or
21
+ // status line such as "⟳ Thinking…" is never a summary.
22
+
23
+ // Spinner and progress glyphs a status line starts with.
24
+ const SPINNER_PREFIX_RE = /^[\s⟳↻↺⏳⌛◌◐◓◑◒○●◴◷◶◵⠁-⣿·•…]+/u;
25
+ const STATUS_WORD_RE = /^(?:thinking|working|waiting|running|loading|processing|starting|reasoning|planning)(?:\s*(?:\.{2,}|…))?$/i;
26
+ // `<detached command with shellId: 1 completed with exit code 0>`,
27
+ // `<shellId: 0 completed with exit code 0>`.
28
+ const SHELL_EXIT_TEXT_RE = /shellId:\s*([^\s>,]+)[^<>]*?completed with exit code\s+(-?\d+)/i;
29
+
30
+ const SUMMARY_MAX = 500;
31
+
32
+ function text(value) {
33
+ return String(value ?? '').trim();
34
+ }
35
+
36
+ /** Whether a progress line is a transient status ("⟳ Thinking…") rather than content. */
37
+ export function isTransientProgressText(value) {
38
+ const line = text(value);
39
+ if (!line) return true;
40
+ const glyphs = SPINNER_PREFIX_RE.exec(line)?.[0] || '';
41
+ const rest = line.slice(glyphs.length).trim();
42
+ if (!rest) return true;
43
+ // A status word alone, with or without a spinner in front.
44
+ return STATUS_WORD_RE.test(rest);
45
+ }
46
+
47
+ /**
48
+ * The exit code an event reports for a shell: `{ shellId, exitCode }` or
49
+ * null. From a `shell_completed` notification that has one, or from a shell
50
+ * tool's result text.
51
+ */
52
+ export function shellExitFromEvent(event) {
53
+ const type = text(event?.type);
54
+ const data = event?.data && typeof event.data === 'object' ? event.data : {};
55
+ if (type === 'system.notification') {
56
+ const kind = data.kind && typeof data.kind === 'object' ? data.kind : {};
57
+ const shellId = text(kind.shellId);
58
+ if (kind.type === 'shell_completed' && shellId && Number.isInteger(kind.exitCode)) {
59
+ return { shellId, exitCode: kind.exitCode };
60
+ }
61
+ return null;
62
+ }
63
+ if (type === 'tool.execution_complete') {
64
+ const result = data.result && typeof data.result === 'object' ? data.result : {};
65
+ const match = SHELL_EXIT_TEXT_RE.exec(`${text(result.content)}\n${text(result.detailedContent)}`);
66
+ if (!match) return null;
67
+ const exitCode = Number(match[2]);
68
+ return Number.isInteger(exitCode) ? { shellId: match[1], exitCode } : null;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * How a task that left the live set ended: `{ status, exitCode, summary }`.
75
+ * `task` is the runtime's entry as `tasks.list()` gives it now (finished),
76
+ * or the last running one when the list no longer has it; `exitCode` what
77
+ * the events said for a shell (null when nothing did).
78
+ */
79
+ export function settleForTask(task, { exitCode = null } = {}) {
80
+ const status = text(task?.status).toLowerCase();
81
+ const code = Number.isInteger(exitCode) ? exitCode : null;
82
+ let settled;
83
+ if (status === 'cancelled') settled = 'stopped';
84
+ else if (status === 'failed') settled = 'failed';
85
+ else if (code !== null) settled = code === 0 ? 'completed' : 'failed';
86
+ else if (status === 'completed') settled = 'completed';
87
+ else settled = 'unknown';
88
+ let summary = null;
89
+ if (task?.type === 'agent') {
90
+ const result = text(settled === 'failed' ? (task.error || task.result) : (task.result || task.latestResponse));
91
+ if (result && !isTransientProgressText(result)) summary = result.slice(0, SUMMARY_MAX);
92
+ } else if (code !== null) {
93
+ summary = `exit code ${code}`;
94
+ }
95
+ return { status: settled, exitCode: code, summary };
96
+ }
@@ -22,6 +22,8 @@ import { migratePreviews } from './migrations/0013-previews.mjs';
22
22
  import { migrateConversationGroups } from './migrations/0014-conversation-groups.mjs';
23
23
  import { migrateConversationGroupPosition } from './migrations/0015-conversation-group-position.mjs';
24
24
  import { migrateShareAccessLog } from './migrations/0016-share-access-log.mjs';
25
+ import { migrateConversationHistoryIndexes } from './migrations/0017-conversation-history-indexes.mjs';
26
+ import { migrateBackgroundTaskRows } from './migrations/0018-background-task-rows.mjs';
25
27
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
26
28
 
27
29
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -836,6 +838,23 @@ try {
836
838
  console.warn(`[shares] migration 0016 failed; retrying on next boot: ${error?.message || error}`);
837
839
  }
838
840
 
841
+ // Two indexes for the history of long conversations. Only speed depends on
842
+ // them, so a failure is logged and retried next boot like 0016.
843
+ try {
844
+ migrateConversationHistoryIndexes(db);
845
+ } catch (error) {
846
+ console.warn(`[history] migration 0017 failed; retrying on next boot: ${error?.message || error}`);
847
+ }
848
+
849
+ // The task console's rows across a restart: one new table. The task store
850
+ // checks for it and keeps its rows in memory only without it, so a failure is
851
+ // logged and retried next boot like 0017.
852
+ try {
853
+ migrateBackgroundTaskRows(db);
854
+ } catch (error) {
855
+ console.warn(`[background-tasks] migration 0018 failed; retrying on next boot: ${error?.message || error}`);
856
+ }
857
+
839
858
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
840
859
  if (runtimeSessionColumns.length) {
841
860
  if (!runtimeSessionColumns.includes('strategy')) {