@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -1,7 +1,9 @@
1
1
  // Pure pieces of the task console and the task rows: the pass/fail chip, the
2
- // stall hint, the ✓/✘ of an ended task, the console's line rows and the
3
- // search for the first failure. DOM nodes are built with createElement and
4
- // textContent only: every string here comes from a worker's output.
2
+ // stall hint, the ✓/✘ of an ended task, the console's line rows, the search
3
+ // for the first failure, the "output stops here" marker of a command piped
4
+ // into tail, and the texts for a task without output (the relay's answer
5
+ // uses them too). DOM nodes are built with createElement and textContent
6
+ // only: every string here comes from a worker's output.
5
7
 
6
8
  import { ansiToSegments, stallMinutes } from './task-output-text.mjs';
7
9
 
@@ -58,19 +60,26 @@ export function stallHint(lastOutputAt, thresholdMinutes, now = Date.now()) {
58
60
  };
59
61
  }
60
62
 
61
- /** ✓ or ✘ for an ended task, from its status and exit code. */
63
+ /**
64
+ * ✓, ✘, ■ or ? for an ended task, from its status and exit code. `unknown`
65
+ * (a task whose worker did not come back after a relay restart) is neither a
66
+ * success nor a failure: a neutral ?, not a green ✓.
67
+ */
62
68
  export function endedOutcome(task) {
63
69
  const status = String(task?.status || '').trim().toLowerCase();
64
70
  const exitCode = task?.exitCode;
65
71
  const hasExit = exitCode !== null && exitCode !== undefined && exitCode !== '' && Number.isFinite(Number(exitCode));
66
72
  const failed = status === 'failed' || (hasExit && Number(exitCode) !== 0);
67
73
  const stopped = status === 'stopped' || status === 'killed' || status === 'cancelled';
74
+ const unknown = !failed && !stopped && !hasExit && status === 'unknown';
68
75
  const label = failed
69
76
  ? (hasExit ? `failed, exit code ${Number(exitCode)}` : 'failed')
70
- : stopped ? 'stopped' : status === 'completed' || (hasExit && Number(exitCode) === 0) ? 'completed' : 'ended';
77
+ : stopped ? 'stopped'
78
+ : unknown ? 'ended, result unknown'
79
+ : status === 'completed' || (hasExit && Number(exitCode) === 0) ? 'completed' : 'ended';
71
80
  return {
72
- icon: failed ? '✘' : stopped ? '■' : '✓',
73
- ok: !failed && !stopped,
81
+ icon: failed ? '✘' : stopped ? '■' : unknown ? '?' : '✓',
82
+ ok: !failed && !stopped && !unknown,
74
83
  failed,
75
84
  label,
76
85
  };
@@ -98,12 +107,165 @@ export function taskOutputUnavailable(task) {
98
107
  return String(task?.outputKind || '').trim() === 'none';
99
108
  }
100
109
 
110
+ /**
111
+ * Why a task has no output to show: the console's `none` answer (the relay's
112
+ * output route imports these) and the row's hover title, in one wording.
113
+ */
114
+ export const TASK_OUTPUT_NONE_MESSAGES = Object.freeze({
115
+ provider: 'Output is not available for this provider.',
116
+ subagent: 'Sub-agents have no shell output; open the sub-agent view.',
117
+ noOutputFile: 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.',
118
+ noPath: 'Output is not available for this task.',
119
+ });
120
+
121
+ // ─── Commands piped into tail ────────────────────────────────────────────────
122
+
123
+ // A command line as its pipeline stages: `{ words, piped }` per simple
124
+ // command, `piped` when a `|` (or `|&`) feeds it. Quotes and backslashes are
125
+ // honoured, `||`, `&&`, `;`, `&` and newlines end a stage without a pipe;
126
+ // `2>&1` and `&>` are redirections, not separators, and a `#` that starts a
127
+ // word comments out the rest of its line. Not a shell: enough to tell where
128
+ // a `| tail` sits.
129
+ function pipelineStages(command) {
130
+ const text = String(command ?? '');
131
+ const stages = [];
132
+ let words = [];
133
+ let word = '';
134
+ let inWord = false;
135
+ let quote = '';
136
+ let piped = false;
137
+ const endWord = () => {
138
+ if (inWord) words.push(word);
139
+ word = '';
140
+ inWord = false;
141
+ };
142
+ const endStage = (nextPiped) => {
143
+ endWord();
144
+ if (words.length) stages.push({ words, piped });
145
+ words = [];
146
+ piped = nextPiped;
147
+ };
148
+ for (let index = 0; index < text.length; index += 1) {
149
+ const char = text[index];
150
+ if (quote) {
151
+ if (char === quote) quote = '';
152
+ else if (char === '\\' && quote === '"' && index + 1 < text.length) word += text[++index];
153
+ else word += char;
154
+ continue;
155
+ }
156
+ if (char === '\\') {
157
+ word += text[index + 1] ?? '';
158
+ inWord = true;
159
+ index += 1;
160
+ continue;
161
+ }
162
+ if (char === '#' && !inWord) {
163
+ while (index + 1 < text.length && text[index + 1] !== '\n') index += 1;
164
+ continue;
165
+ }
166
+ if (char === "'" || char === '"') {
167
+ quote = char;
168
+ inWord = true;
169
+ continue;
170
+ }
171
+ if (char === '|') {
172
+ if (text[index + 1] === '|') {
173
+ index += 1;
174
+ endStage(false);
175
+ continue;
176
+ }
177
+ if (text[index + 1] === '&') index += 1;
178
+ endStage(true);
179
+ continue;
180
+ }
181
+ if (char === '&') {
182
+ const redirect = text[index - 1] === '>' || text[index - 1] === '<' || text[index + 1] === '>';
183
+ if (!redirect) {
184
+ if (text[index + 1] === '&') index += 1;
185
+ endStage(false);
186
+ continue;
187
+ }
188
+ }
189
+ if (char === ';' || char === '\n' || char === '(' || char === ')') {
190
+ endStage(false);
191
+ continue;
192
+ }
193
+ if (/\s/.test(char)) {
194
+ endWord();
195
+ continue;
196
+ }
197
+ word += char;
198
+ inWord = true;
199
+ }
200
+ endStage(false);
201
+ return stages;
202
+ }
203
+
204
+ // tail -f / -F / --follow streams; so does tail from a line (`-n +2`, `+2`).
205
+ function tailStreams(args) {
206
+ for (let index = 0; index < args.length; index += 1) {
207
+ const arg = args[index];
208
+ if (/^--follow\b/.test(arg) || /^-[A-Za-z0-9]*[fF]/.test(arg)) return true;
209
+ if (/^\+\d/.test(arg) || /^-[nc]\+\d/.test(arg) || /^--(?:lines|bytes)=\+/.test(arg)) return true;
210
+ if ((arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') && /^\+\d/.test(args[index + 1] || '')) return true;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ /**
216
+ * 'tail' when the command line pipes into a tail that holds the output back
217
+ * until the command ends (`npm test 2>&1 | tail -40`), else ''. `tail -f`
218
+ * and `tail -n +N` stream and do not count; neither does a `|` inside quotes
219
+ * or a comment. `head` passes its lines on as they come (and ends the
220
+ * command early), so it holds nothing back.
221
+ */
222
+ export function pipeHoldProgram(command) {
223
+ for (const { words, piped } of pipelineStages(command)) {
224
+ if (!piped) continue;
225
+ let at = 0;
226
+ while (at < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at])) at += 1;
227
+ const program = String(words[at] || '').split('/').pop();
228
+ if (program === 'tail' && !tailStreams(words.slice(at + 1))) return 'tail';
229
+ }
230
+ return '';
231
+ }
232
+
233
+ /** 'tail' when a task's command line (its `command`, else its description) pipes into a holding tail. */
234
+ export function taskPipeHold(task) {
235
+ return pipeHoldProgram(task?.command) || pipeHoldProgram(task?.description);
236
+ }
237
+
238
+ /** What the console and the quiet row say about a command piped into `program`. */
239
+ export function pipeHoldNotice(program) {
240
+ if (!program) return null;
241
+ const hint = 'Logging to a file instead (> run.log 2>&1) and following that file shows it live.';
242
+ return {
243
+ text: `Output stops here: this command pipes into ${program}, so the rest arrives when it ends.`,
244
+ hint,
245
+ title: `This command pipes into ${program}, so its output arrives when it ends. ${hint}`,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Whether the console shows the "output stops here" marker: a live task
251
+ * whose command pipes into tail, as long as nothing came in since
252
+ * the console opened (`openedAt` the end offset of the first read).
253
+ */
254
+ export function showPipeHoldMarker({ program, ended, kind, openedAt, nextOffset }) {
255
+ if (!program || ended || kind === 'none' || !kind) return false;
256
+ return !Number.isFinite(Number(openedAt)) || Number(nextOffset) <= Number(openedAt);
257
+ }
258
+
101
259
  // ─── Line rows ───────────────────────────────────────────────────────────────
102
260
 
261
+ // Inverse text arrives with its colours swapped (task-output-text.mjs); a
262
+ // side left unset takes the console's own colour of the other side.
103
263
  function segmentClass(segment) {
104
264
  const classes = [];
105
265
  if (Number.isInteger(segment.fg)) classes.push(`tc-fg-${segment.fg}`);
266
+ else if (segment.inverse) classes.push('tc-fg-inverse');
106
267
  if (Number.isInteger(segment.bg)) classes.push(`tc-bg-${segment.bg}`);
268
+ else if (segment.inverse) classes.push('tc-bg-inverse');
107
269
  if (segment.bold) classes.push('tc-bold');
108
270
  if (segment.dim) classes.push('tc-dim');
109
271
  if (segment.italic) classes.push('tc-italic');
@@ -188,6 +350,8 @@ export function consoleMetaParts({ task, elapsed, ended, status, exitCode, kind
188
350
  parts.push(type === 'monitor' ? 'Monitor' : type === 'local_bash' ? 'Shell' : (type || 'Task'));
189
351
  if (elapsed) parts.push(elapsed);
190
352
  if (ended) parts.push(endedOutcome({ status, exitCode }).label);
353
+ // Held since a relay restart until its worker reports.
354
+ else if (status === 'held') parts.push('waiting for its worker after the relay restart');
191
355
  else parts.push('running');
192
356
  if (kind === 'snapshot') parts.push('last 8 KB, read again every 3 s');
193
357
  if (kind === 'spool' && task?.mirrored) parts.push("output mirrored from the runtime's recent window");
@@ -4,9 +4,13 @@
4
4
  // as `task_output_chunk`, which the relay sends only to pages watching the
5
5
  // task (task-console-watch.mjs). A chunk that does not continue where the
6
6
  // view stands (a dropped chunk, a rewritten file) reads the tail again.
7
- // "Load earlier" pages back up to 5 MB. A provider that can only give a
8
- // snapshot is read again every 3 s instead. Full-screen on phones; Escape,
9
- // ×, a click outside and the system back close it.
7
+ // "Load earlier" pages back up to 5 MB (from the top of the output, or the
8
+ // header while following the end). A provider that can only give a snapshot
9
+ // is read again every 3 s instead. A command piped into tail gets a
10
+ // marker where its output stops until it ends. On wider screens the console
11
+ // ends above the task's row in the composer panel, so the row's chip stays
12
+ // in view; full-screen on phones. Escape, ×, a click outside and the system
13
+ // back close it.
10
14
 
11
15
  import { apiFetchOrThrow } from './api-client.js';
12
16
  import { currentConvId } from './store.js';
@@ -19,22 +23,32 @@ import {
19
23
  getBackgroundTaskHealth,
20
24
  getTaskStallMinutes,
21
25
  isBackgroundTaskStopping,
26
+ onBackgroundTasksPanelRendered,
22
27
  stopBackgroundTask,
23
28
  subscribeBackgroundTasks,
24
29
  } from './background-tasks-view.mjs';
25
30
  import {
31
+ TASK_OUTPUT_NONE_MESSAGES,
26
32
  buildLineNode,
27
33
  chunkAction,
28
34
  chunkEndOffset,
29
35
  consoleMetaParts,
30
36
  findFirstFailureIndex,
31
37
  loadEarlierState,
38
+ pipeHoldNotice,
32
39
  runnerChip,
40
+ showPipeHoldMarker,
33
41
  stallHint,
42
+ taskPipeHold,
34
43
  } from './task-console-render.mjs';
35
44
 
36
45
  const TAIL_LINES = 2000;
37
46
  const SNAPSHOT_POLL_MS = 3000;
47
+ // Wider than a phone, the console stops above the task's row when the row's
48
+ // top is at least this far down the window (the console keeps about 320 px
49
+ // of it); otherwise it covers the page as before.
50
+ const PHONE_MAX_WIDTH = 680;
51
+ const ABOVE_ROW_MIN_TOP = 360;
38
52
 
39
53
  const tc = {
40
54
  open: false,
@@ -56,6 +70,8 @@ const tc = {
56
70
  partialNode: null,
57
71
  startOffset: 0,
58
72
  nextOffset: 0,
73
+ // Where the output stood at the first read: past it, output came in.
74
+ openedAt: null,
59
75
  size: 0,
60
76
  hasMoreBefore: false,
61
77
  loadedBytes: 0,
@@ -86,7 +102,9 @@ function elements() {
86
102
  stop: document.getElementById('task-console-stop'),
87
103
  scroll: document.getElementById('task-console-scroll'),
88
104
  earlier: document.getElementById('task-console-earlier'),
105
+ earlierTop: document.getElementById('task-console-earlier-top'),
89
106
  body: document.getElementById('task-console-body'),
107
+ hold: document.getElementById('task-console-hold'),
90
108
  status: document.getElementById('task-console-status'),
91
109
  jump: document.getElementById('task-console-jump'),
92
110
  };
@@ -139,11 +157,13 @@ function renderHeader() {
139
157
  }).join(' · ');
140
158
  }
141
159
  const health = getBackgroundTaskHealth(tc.conversationId, tc.taskId) || tc.health;
160
+ const holdNotice = pipeHoldNotice(taskPipeHold(task));
142
161
  if (stall) {
143
162
  const hint = ended ? null : stallHint(health?.lastOutputAt, getTaskStallMinutes(tc.conversationId));
144
163
  stall.hidden = !hint;
145
164
  stall.textContent = hint ? hint.text : '';
146
165
  stall.className = hint?.warn ? 'tc-stall tc-stall-warn' : 'tc-stall';
166
+ stall.title = holdNotice ? holdNotice.title : '';
147
167
  }
148
168
  const chipInfo = runnerChip(health);
149
169
  if (chip) {
@@ -160,6 +180,56 @@ function renderHeader() {
160
180
  stop.disabled = stopping;
161
181
  stop.textContent = stopping ? 'Stopping…' : 'Stop';
162
182
  }
183
+ renderHold();
184
+ syncLayout();
185
+ }
186
+
187
+ /** The "output stops here" marker under the last line, while nothing new came. */
188
+ function renderHold() {
189
+ const { hold } = elements();
190
+ if (!hold) return;
191
+ const found = currentTask();
192
+ const program = taskPipeHold(found?.task || tc.answerTask);
193
+ const notice = pipeHoldNotice(program);
194
+ const show = !!notice && !tc.loading && showPipeHoldMarker({
195
+ program,
196
+ ended: tc.ended || !!found?.ended,
197
+ kind: tc.kind,
198
+ openedAt: tc.openedAt,
199
+ nextOffset: tc.nextOffset,
200
+ });
201
+ hold.hidden = !show;
202
+ if (!show) return;
203
+ hold.querySelector('.tc-hold-text').textContent = notice.text;
204
+ hold.querySelector('.tc-hold-hint').textContent = notice.hint;
205
+ }
206
+
207
+ /**
208
+ * Wider than a phone, the modal ends above the task's row in the composer
209
+ * panel, so the row (its ✓/✘ chip, clock and Stop) stays in view and live.
210
+ * Without the row on screen, or with too little room above it, the console
211
+ * covers the page as before.
212
+ */
213
+ function syncLayout() {
214
+ const { modal } = elements();
215
+ if (!modal) return;
216
+ let bottom = 0;
217
+ if (tc.open && window.innerWidth > PHONE_MAX_WIDTH) {
218
+ const panel = document.getElementById('background-tasks-panel');
219
+ const row = panel && !panel.hidden && panel.hasAttribute('open')
220
+ ? panel.querySelector(`.bg-task-row[data-task-id="${CSS.escape(tc.taskId)}"]`)
221
+ : null;
222
+ const rect = row?.getBoundingClientRect?.();
223
+ if (rect && rect.height > 0 && rect.top >= ABOVE_ROW_MIN_TOP && rect.top < window.innerHeight) {
224
+ bottom = Math.ceil(window.innerHeight - rect.top + 6);
225
+ }
226
+ }
227
+ modal.classList.toggle('tc-above-panel', bottom > 0);
228
+ modal.style.bottom = bottom > 0 ? `${bottom}px` : '';
229
+ // Above the panel the page below stays usable: the console is no modal then.
230
+ const dialog = modal.querySelector('[role="dialog"]');
231
+ if (bottom > 0) dialog?.removeAttribute('aria-modal');
232
+ else dialog?.setAttribute('aria-modal', 'true');
163
233
  }
164
234
 
165
235
  function renderStatus() {
@@ -168,7 +238,7 @@ function renderStatus() {
168
238
  const hasLines = tc.earlierLines.length || tc.folded.lines.length || tc.folded.partial;
169
239
  let text = '';
170
240
  if (tc.error) text = tc.error;
171
- else if (tc.kind === 'none') text = tc.message || 'output not available for this provider';
241
+ else if (tc.kind === 'none') text = tc.message || TASK_OUTPUT_NONE_MESSAGES.provider;
172
242
  else if (tc.loading && !hasLines) text = 'Loading…';
173
243
  else if (!hasLines && !tc.note) text = tc.ended ? 'This task wrote no output.' : 'No output yet. Lines appear as the task writes them.';
174
244
  if (tc.note) text = text ? `${text} ${tc.note}` : tc.note;
@@ -176,13 +246,17 @@ function renderStatus() {
176
246
  status.hidden = !text;
177
247
  }
178
248
 
249
+ // Both "Load earlier" buttons: the one above the first line, and the
250
+ // header's, which is in reach while following the end.
179
251
  function renderEarlierButton() {
180
- const { earlier } = elements();
181
- if (!earlier) return;
252
+ const { earlier, earlierTop } = elements();
182
253
  const state = loadEarlierState({ hasMoreBefore: tc.hasMoreBefore, loadedBytes: tc.loadedBytes, loading: tc.loadingEarlier });
183
- earlier.hidden = state.hidden;
184
- earlier.disabled = state.disabled;
185
- earlier.textContent = state.text;
254
+ for (const button of [earlier, earlierTop]) {
255
+ if (!button) continue;
256
+ button.hidden = state.hidden;
257
+ button.disabled = state.disabled;
258
+ button.textContent = state.text;
259
+ }
186
260
  }
187
261
 
188
262
  // ─── Lines ───────────────────────────────────────────────────────────────────
@@ -202,6 +276,7 @@ function clearLines() {
202
276
  function afterContentChange(appended) {
203
277
  const { scroll, jump } = elements();
204
278
  renderStatus();
279
+ renderHold();
205
280
  if (!scroll) return;
206
281
  if (tc.following) {
207
282
  scroll.scrollTop = scroll.scrollHeight;
@@ -340,6 +415,7 @@ async function fetchTail({ before = null } = {}) {
340
415
  tc.nextOffset = Number.isFinite(Number(data.endOffset)) ? Number(data.endOffset) : Number(data.size) || 0;
341
416
  tc.size = Number(data.size) || tc.nextOffset;
342
417
  tc.hasMoreBefore = data.hasMoreBefore === true;
418
+ if (tc.openedAt === null) tc.openedAt = tc.nextOffset;
343
419
  if (typeof data.text === 'string') {
344
420
  feed(data.text);
345
421
  tc.loadedBytes = Math.max(0, tc.nextOffset - tc.startOffset) || data.text.length;
@@ -357,7 +433,11 @@ async function fetchTail({ before = null } = {}) {
357
433
  for (const chunk of pending) applyChunk(chunk);
358
434
  }
359
435
 
360
- async function loadEarlier() {
436
+ /**
437
+ * Pages back. `reveal` (the header's button): the view goes to the lines
438
+ * just loaded, at the top, instead of staying where the reader was.
439
+ */
440
+ async function loadEarlier({ reveal = false } = {}) {
361
441
  if (!tc.open || tc.loadingEarlier || !tc.hasMoreBefore) return;
362
442
  if (loadEarlierState({ hasMoreBefore: true, loadedBytes: tc.loadedBytes }).disabled) return;
363
443
  const token = tc.fetchToken;
@@ -372,6 +452,11 @@ async function loadEarlier() {
372
452
  if (Number.isFinite(start)) tc.startOffset = start;
373
453
  tc.hasMoreBefore = data.hasMoreBefore === true;
374
454
  prependEarlier(lines);
455
+ const { scroll } = elements();
456
+ if (reveal && scroll && lines.length) {
457
+ tc.following = false;
458
+ scroll.scrollTop = 0;
459
+ }
375
460
  } catch (error) {
376
461
  if (token === tc.fetchToken) {
377
462
  tc.note = errorText(error);
@@ -452,6 +537,7 @@ function resetState() {
452
537
  tc.loadingEarlier = false;
453
538
  tc.startOffset = 0;
454
539
  tc.nextOffset = 0;
540
+ tc.openedAt = null;
455
541
  tc.size = 0;
456
542
  tc.hasMoreBefore = false;
457
543
  tc.loadedBytes = 0;
@@ -505,6 +591,8 @@ export function closeTaskConsole({ fromHistory = false } = {}) {
505
591
  consoleWatch.unwatch();
506
592
  clearLines();
507
593
  resetState();
594
+ syncLayout();
595
+ renderHold();
508
596
  syncSnapshotPoll();
509
597
  syncTicker();
510
598
  if (!fromHistory) consoleHistory?.closed();
@@ -604,13 +692,17 @@ export function initTaskConsole() {
604
692
  renderHeader();
605
693
  });
606
694
 
607
- const { modal, stop, scroll, jump, earlier, failure, chip } = elements();
695
+ const { modal, stop, scroll, jump, earlier, earlierTop, failure, chip } = elements();
608
696
  modal?.addEventListener('click', (event) => {
609
697
  if (event.target === modal) closeTaskConsole();
610
698
  });
611
699
  modal?.querySelector('.task-console-close')?.addEventListener('click', () => closeTaskConsole());
612
700
  stop?.addEventListener('click', () => void stopTask());
613
701
  earlier?.addEventListener('click', () => void loadEarlier());
702
+ earlierTop?.addEventListener('click', () => void loadEarlier({ reveal: true }));
703
+ window.addEventListener('resize', () => {
704
+ if (tc.open) syncLayout();
705
+ });
614
706
  failure?.addEventListener('click', () => jumpToFirstFailure());
615
707
  chip?.addEventListener('click', () => jumpToFirstFailure());
616
708
  jump?.addEventListener('click', () => {
@@ -624,7 +716,15 @@ export function initTaskConsole() {
624
716
  }, { passive: true });
625
717
  document.addEventListener('keydown', (event) => {
626
718
  if (event.key !== 'Escape' || !tc.open) return;
719
+ // Above the panel, Escape in the composer (or any field outside the
720
+ // console) is that field's own.
721
+ if (modal?.classList.contains('tc-above-panel') && !modal.contains(event.target)
722
+ && event.target?.closest?.('input, textarea, select, [contenteditable="true"], [contenteditable=""]')) return;
627
723
  event.preventDefault();
628
724
  closeTaskConsole();
629
725
  });
726
+ // The panel drew its rows again (a fold, a task set): the row may have moved.
727
+ onBackgroundTasksPanelRendered(() => {
728
+ if (tc.open) syncLayout();
729
+ });
630
730
  }
@@ -163,7 +163,7 @@ function clampByte(value) {
163
163
  }
164
164
 
165
165
  function plainStyle() {
166
- return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
166
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, inverse: false };
167
167
  }
168
168
 
169
169
  function applySgr(state, params) {
@@ -175,11 +175,13 @@ function applySgr(state, params) {
175
175
  else if (code === 2) state.dim = true;
176
176
  else if (code === 3) state.italic = true;
177
177
  else if (code === 4) state.underline = true;
178
+ else if (code === 7) state.inverse = true;
178
179
  else if (code === 22) {
179
180
  state.bold = false;
180
181
  state.dim = false;
181
182
  } else if (code === 23) state.italic = false;
182
183
  else if (code === 24) state.underline = false;
184
+ else if (code === 27) state.inverse = false;
183
185
  else if (code >= 30 && code <= 37) state.fg = code - 30;
184
186
  else if (code === 39) state.fg = null;
185
187
  else if (code >= 40 && code <= 47) state.bg = code - 40;
@@ -204,8 +206,11 @@ function applySgr(state, params) {
204
206
  /**
205
207
  * One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
206
208
  * colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
207
- * Adjacent segments never share a style. Any escape that is not SGR is
208
- * dropped from the text.
209
+ * Inverse text (SGR 7, until 27 or 0) comes with its colours already swapped
210
+ * and `inverse: true`: a null `fg` then means the default background as the
211
+ * text colour, a null `bg` the default text colour as the background (the
212
+ * filled RUN / PASS / FAIL badges of vitest). Adjacent segments never share a
213
+ * style. Any escape that is not SGR is dropped from the text.
209
214
  */
210
215
  export function ansiToSegments(lineText) {
211
216
  const text = String(lineText ?? '');
@@ -214,13 +219,17 @@ export function ansiToSegments(lineText) {
214
219
  const pushText = (value) => {
215
220
  const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
216
221
  if (!clean) return;
222
+ const fg = state.inverse ? state.bg : state.fg;
223
+ const bg = state.inverse ? state.fg : state.bg;
217
224
  const last = segments[segments.length - 1];
218
- if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
219
- && last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
225
+ if (last && last.fg === fg && last.bg === bg && last.bold === state.bold && last.dim === state.dim
226
+ && last.italic === state.italic && last.underline === state.underline && !!last.inverse === state.inverse) {
220
227
  last.text += clean;
221
228
  return;
222
229
  }
223
- segments.push({ text: clean, ...state });
230
+ const segment = { text: clean, fg, bg, bold: state.bold, dim: state.dim, italic: state.italic, underline: state.underline };
231
+ if (state.inverse) segment.inverse = true;
232
+ segments.push(segment);
224
233
  };
225
234
  let at = 0;
226
235
  const pattern = new RegExp(SGR_PATTERN.source, 'g');
@@ -29,6 +29,7 @@ import {
29
29
  shouldHoldFocusOnJump,
30
30
  } from './composer-keyboard.mjs';
31
31
  import {
32
+ baselineBeforeUpdate,
32
33
  isScrollOnSendEnabled,
33
34
  followsEndThroughResize,
34
35
  isNearBottom,
@@ -197,13 +198,16 @@ export function followsTranscriptEnd() {
197
198
  if (!isConversationWindowAtTail()) return false;
198
199
  const el = scroller();
199
200
  const height = jump ? null : observedClientHeight;
200
- return followsEndThroughResize({
201
+ const follows = followsEndThroughResize({
201
202
  nearBottomNow: isMessagesNearBottom(),
202
203
  clientHeight: el?.clientHeight,
203
204
  observedClientHeight: height,
204
205
  distanceAtObservedHeight: el && height !== null ? Math.max(0, el.scrollHeight - height - el.scrollTop) : null,
205
206
  nearBottomPxAtObservedHeight: height !== null ? resolveNearBottomThresholdPx(height) : 0,
206
207
  });
208
+ // What the update adds is counted against the rows on the page before it.
209
+ if (!follows && el && !jump && syncStateConversation()) state = baselineBeforeUpdate(state, rowKeys(el));
210
+ return follows;
207
211
  }
208
212
 
209
213
  /**
@@ -103,6 +103,26 @@ export function nextJumpButtonState(previous, {
103
103
  };
104
104
  }
105
105
 
106
+ /**
107
+ * The state before a live update that does not follow the end changes the
108
+ * transcript: the baseline is taken now, from the rows before it, if there
109
+ * is none yet. Otherwise it is taken by the next frame's nextJumpButtonState,
110
+ * by then with the update's row in it, and a row that lands between the
111
+ * reader's scroll away and that frame (one frame, or a tenth of a second on
112
+ * a busy page) is not counted.
113
+ */
114
+ export function baselineBeforeUpdate(previous, rowKeys = []) {
115
+ if (Array.isArray(previous?.baseline)) return previous;
116
+ return {
117
+ visible: previous?.visible === true,
118
+ baseline: rowKeys.slice(),
119
+ count: 0,
120
+ unseen: previous?.unseen === true,
121
+ question: previous?.question === true,
122
+ waiting: 0,
123
+ };
124
+ }
125
+
106
126
  export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
107
127
  export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
108
128
  export const JUMP_QUESTION_BADGE = '?';