@oar-sh/oar 0.9.11 → 0.9.12

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 (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  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 +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -0,0 +1,256 @@
1
+ // Where a reader is in a conversation, kept across a switch to another one
2
+ // and across a page reload, and the window of loaded messages kept with it.
3
+ // No DOM here: the caller measures the rows and applies what is decided.
4
+ //
5
+ // A pixel scrollTop does not survive either trip. The rows on the page after
6
+ // a switch back are another set (the oldest dropped by the page-size cap,
7
+ // new ones written meanwhile at the end, a window centred on the place read
8
+ // after a reload), so the same pixel count lands on another message. The
9
+ // position is therefore a message and how far its top sits from the pane's
10
+ // top, or "at the end".
11
+ //
12
+ // Finer than the reading block of transcript-viewport-anchor.mjs, coarser
13
+ // than it too: that one is for rows rebuilt under the reader with other
14
+ // heights, and holds the text read. This one goes to localStorage, so it
15
+ // holds an id and an offset only.
16
+
17
+ import { sortConversationMessages } from './thread-order.mjs';
18
+
19
+ function messageIdOf(message) {
20
+ return String(message?.id || '').trim();
21
+ }
22
+
23
+ /**
24
+ * The index of the first row whose bottom edge is below the pane's top (the
25
+ * row being read), or -1. `bottomAt(i)` is the row's bottom in the pane's
26
+ * coordinates; rows are in page order, so their bottoms only grow and a
27
+ * binary search reads a handful of them on a long transcript.
28
+ */
29
+ export function findReadingRowIndex(rowCount, bottomAt, paneTop) {
30
+ const count = Math.max(0, Math.trunc(Number(rowCount) || 0));
31
+ let low = 0;
32
+ let high = count;
33
+ const edge = Number(paneTop) + 1;
34
+ while (low < high) {
35
+ const mid = (low + high) >> 1;
36
+ if (Number(bottomAt(mid)) > edge) high = mid;
37
+ else low = mid + 1;
38
+ }
39
+ return low < count ? low : -1;
40
+ }
41
+
42
+ /**
43
+ * The reader's position from the pane and its rows. `rowAt(i)` gives
44
+ * `{ messageId, top, bottom }`; `atTail` is true when the reader is at the
45
+ * end of a window that is the end of the conversation (they follow live
46
+ * rows). Null when no row is on the page.
47
+ */
48
+ export function captureReadingAnchor({ rowCount, rowAt, paneTop, atTail = false }) {
49
+ const index = findReadingRowIndex(rowCount, (i) => rowAt(i)?.bottom, paneTop);
50
+ if (index < 0) return null;
51
+ const row = rowAt(index);
52
+ const messageId = String(row?.messageId || '').trim();
53
+ if (!messageId) return null;
54
+ return {
55
+ messageId,
56
+ offsetPx: Math.round(Number(row.top) - Number(paneTop)),
57
+ atTail: atTail === true,
58
+ };
59
+ }
60
+
61
+ /** How far to scroll so the anchored row's top is at its saved offset again. */
62
+ export function readingAnchorScrollDelta({ rowTop, paneTop, offsetPx }) {
63
+ return Number(rowTop) - Number(paneTop) - Number(offsetPx || 0);
64
+ }
65
+
66
+ /**
67
+ * Where a row's top sits in the content, whatever the scroll: its distance
68
+ * from the pane's top plus how far the pane is scrolled. It changes only when
69
+ * something above the row changes height.
70
+ */
71
+ export function readingAnchorContentTop({ rowTop, paneTop, scrollTop }) {
72
+ return Number(rowTop) - Number(paneTop) + Number(scrollTop);
73
+ }
74
+
75
+ /**
76
+ * One look at the anchored row while the images above it are still loading
77
+ * (they grow from nothing to their height after the restore, and a browser
78
+ * without scroll anchoring lets that push the row down). `seen` is the row's
79
+ * content position and the pane's scrollTop as of the last look.
80
+ *
81
+ * The total height is no guide: growth below the row (a reply streaming in
82
+ * at the end) changes it too, and a scroll in the same frame (a jump to the
83
+ * end, a key, a drag of the scrollbar) would be taken back. So only the
84
+ * row's own content position counts, and a scroll is told apart from it:
85
+ * - 'stay': nothing above the row changed, or the browser's own scroll
86
+ * anchoring already scrolled by exactly what grew above it.
87
+ * - 'correct': something above the row grew by `delta` and nothing else
88
+ * scrolled: scroll by `delta`.
89
+ * - 'release': scrollTop changed for any other reason. That is the reader
90
+ * (or a jump, a send) and the place is theirs from now on, even when
91
+ * something above grew in the same frame.
92
+ * `rowContentTop` is the position to remember for the next look.
93
+ */
94
+ export function readingAnchorHoldStep({ rowTop, paneTop, scrollTop, seen, tolerancePx = 1 }) {
95
+ const rowContentTop = readingAnchorContentTop({ rowTop, paneTop, scrollTop });
96
+ const grewAbove = rowContentTop - Number(seen?.rowContentTop);
97
+ const scrolled = Number(scrollTop) - Number(seen?.scrollTop);
98
+ if (!Number.isFinite(grewAbove) || !Number.isFinite(scrolled)) return { action: 'release', delta: 0, rowContentTop };
99
+ const grew = Math.abs(grewAbove) > tolerancePx;
100
+ if (Math.abs(scrolled) > tolerancePx) {
101
+ if (grew && Math.abs(scrolled - grewAbove) <= tolerancePx) return { action: 'stay', delta: 0, rowContentTop };
102
+ return { action: 'release', delta: 0, rowContentTop };
103
+ }
104
+ if (grew) return { action: 'correct', delta: grewAbove, rowContentTop };
105
+ return { action: 'stay', delta: 0, rowContentTop };
106
+ }
107
+
108
+ /** An anchor read back from storage, or null when it is not one. */
109
+ export function normalizeReadingAnchor(value) {
110
+ if (!value || typeof value !== 'object') return null;
111
+ const messageId = String(value.messageId || '').trim();
112
+ const atTail = value.atTail === true;
113
+ if (!messageId && !atTail) return null;
114
+ const offset = Number(value.offsetPx);
115
+ return {
116
+ messageId: messageId || null,
117
+ offsetPx: Number.isFinite(offset) ? Math.round(offset) : 0,
118
+ atTail,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The part of a loaded window kept for a switch back: at most `cap` messages
124
+ * (no cap when it is not a positive finite number). A reader at the end keeps
125
+ * the newest; anyone else keeps the messages around the one they read, half
126
+ * above and half below, so either direction can be read on without a fetch.
127
+ * Rows cut off on a side mean there is more on that side to load.
128
+ */
129
+ export function trimMessageWindow(messages, {
130
+ cap = Infinity,
131
+ anchorMessageId = '',
132
+ atTail = false,
133
+ hasMoreOlder = false,
134
+ hasMoreNewer = false,
135
+ } = {}) {
136
+ const list = Array.isArray(messages) ? messages : [];
137
+ const limit = Number(cap);
138
+ if (!(limit > 0) || !Number.isFinite(limit) || list.length <= limit) {
139
+ return { messages: list, hasMoreOlder: !!hasMoreOlder, hasMoreNewer: !!hasMoreNewer, trimmed: false };
140
+ }
141
+ const keep = Math.trunc(limit);
142
+ const ordered = sortConversationMessages(list);
143
+ const anchorId = String(anchorMessageId || '').trim();
144
+ const anchorIndex = !atTail && anchorId ? ordered.findIndex((message) => messageIdOf(message) === anchorId) : -1;
145
+ let start;
146
+ if (anchorIndex < 0) {
147
+ start = ordered.length - keep;
148
+ } else {
149
+ start = Math.max(0, anchorIndex - Math.floor(keep / 2));
150
+ start = Math.min(start, ordered.length - keep);
151
+ }
152
+ const end = start + keep;
153
+ return {
154
+ messages: ordered.slice(start, end),
155
+ hasMoreOlder: !!hasMoreOlder || start > 0,
156
+ hasMoreNewer: !!hasMoreNewer || end < ordered.length,
157
+ trimmed: true,
158
+ };
159
+ }
160
+
161
+ function timestampMsOf(message) {
162
+ const ms = Date.parse(message?.timestamp || 0);
163
+ return Number.isFinite(ms) ? ms : 0;
164
+ }
165
+
166
+ /**
167
+ * The relay's history order: timestamp in ms, then id by localeCompare (as
168
+ * conversation-history-page.mjs pages it). The page's range is measured in
169
+ * this order, not in the thread order the transcript is drawn in: the relay
170
+ * cuts its pages by it, so a row inside the page's range in this order is a
171
+ * row the relay would have put on the page.
172
+ */
173
+ export function compareHistoryOrder(a, b) {
174
+ const aMs = timestampMsOf(a);
175
+ const bMs = timestampMsOf(b);
176
+ if (aMs !== bMs) return aMs - bMs;
177
+ return messageIdOf(a).localeCompare(messageIdOf(b));
178
+ }
179
+
180
+ /**
181
+ * The newest page of a conversation merged into a kept window that reaches
182
+ * the end. A message of the page replaces the one with its id (its text may
183
+ * have changed since: a reply that was still streaming), the others join in
184
+ * conversation order. Null when the page does not meet the window: more rows
185
+ * were written than the page holds, and the rows between are unknown.
186
+ * `changed` and `added` are the ids the page brought that differ from, or
187
+ * are missing in, the window (`sameMessage` decides what differs).
188
+ * The page is the truth for its own range, from its oldest row to its
189
+ * newest in the relay's order: a window row inside it that the page lacks
190
+ * was deleted while the reader was away and is dropped (`removed`). Rows
191
+ * older than the page are kept, the page says nothing about them; so are
192
+ * rows newer than it, which the socket may have brought after the page was
193
+ * read. With `windowPredatesPage` every window row was there before the page
194
+ * was asked for (the caller merged a window read before the request, or no
195
+ * live row arrived while it was on its way): the page, which ends at the
196
+ * conversation's end, then is the truth up to that end, and a newer window
197
+ * row it lacks was deleted too (the relay's own follow-up dropped by a
198
+ * Cancel, say: the newest row of the conversation).
199
+ */
200
+ export function mergeTailPage(windowMessages, pageMessages, {
201
+ pageHasMoreOlder = false,
202
+ windowPredatesPage = false,
203
+ sameMessage = (a, b) => JSON.stringify(a) === JSON.stringify(b),
204
+ } = {}) {
205
+ const kept = Array.isArray(windowMessages) ? windowMessages : [];
206
+ const page = Array.isArray(pageMessages) ? pageMessages : [];
207
+ const keptById = new Map();
208
+ for (const message of kept) {
209
+ const id = messageIdOf(message);
210
+ if (id) keptById.set(id, message);
211
+ }
212
+ const meets = !pageHasMoreOlder || page.some((message) => keptById.has(messageIdOf(message)));
213
+ if (!meets) return null;
214
+ const changed = [];
215
+ const added = [];
216
+ const pageById = new Map();
217
+ for (const message of page) {
218
+ const id = messageIdOf(message);
219
+ if (!id) continue;
220
+ pageById.set(id, message);
221
+ if (!keptById.has(id)) added.push(id);
222
+ else if (!sameMessage(keptById.get(id), message)) changed.push(id);
223
+ }
224
+ let oldest = null;
225
+ let newest = null;
226
+ for (const message of pageById.values()) {
227
+ if (!oldest || compareHistoryOrder(message, oldest) < 0) oldest = message;
228
+ if (!newest || compareHistoryOrder(message, newest) > 0) newest = message;
229
+ }
230
+ // An empty page that is the whole conversation (meets without a shared
231
+ // row) holds nothing: every window row is gone, when the window predates it.
232
+ const insidePage = (message) => {
233
+ if (!oldest) return windowPredatesPage && !pageHasMoreOlder;
234
+ if (compareHistoryOrder(message, oldest) < 0) return false;
235
+ return windowPredatesPage || compareHistoryOrder(message, newest) <= 0;
236
+ };
237
+ const removed = [];
238
+ const merged = [];
239
+ for (const message of kept) {
240
+ const id = messageIdOf(message);
241
+ if (pageById.has(id)) {
242
+ merged.push(pageById.get(id));
243
+ } else if (id && insidePage(message)) {
244
+ removed.push(id);
245
+ } else {
246
+ merged.push(message);
247
+ }
248
+ }
249
+ for (const id of added) merged.push(pageById.get(id));
250
+ return {
251
+ messages: added.length ? sortConversationMessages(merged) : merged,
252
+ changed,
253
+ added,
254
+ removed,
255
+ };
256
+ }
@@ -12,7 +12,8 @@ export const LIVE_ROW_RELOAD_ATTEMPTS = 3;
12
12
  /**
13
13
  * `stale` is true when rows still arrived during the last attempt: the page
14
14
  * holds rows the answer lacks, so the caller leaves the transcript alone and
15
- * the next reload asks again.
15
+ * the next reload asks again (unless the page holds an older copy, see
16
+ * shouldKeepTranscriptOnReload).
16
17
  */
17
18
  export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LIVE_ROW_RELOAD_ATTEMPTS } = {}) {
18
19
  let response = null;
@@ -23,3 +24,18 @@ export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LI
23
24
  }
24
25
  return { response, stale: true };
25
26
  }
27
+
28
+ /**
29
+ * Whether a reload leaves the transcript as it is. A jump into the history
30
+ * (`jumped`) always wins. A `stale` answer is kept off the page only when
31
+ * the page shows the live transcript (the live poll): it then holds every
32
+ * row the answer has and the socket's newer ones. After a kept copy was
33
+ * drawn on a switch (`showsLiveTranscript` false) the page holds an old
34
+ * copy instead, and the rows stored while the reader was away exist only
35
+ * in the answer: the answer is the closer one, and the next reload brings
36
+ * whatever socket row it still lacks.
37
+ */
38
+ export function shouldKeepTranscriptOnReload({ stale = false, jumped = false, showsLiveTranscript = true } = {}) {
39
+ if (jumped) return true;
40
+ return !!stale && !!showsLiveTranscript;
41
+ }
@@ -127,6 +127,24 @@ function isReadable(block, rect) {
127
127
  return typeof block.closest !== 'function' || !block.closest('details:not([open]) > :not(summary)');
128
128
  }
129
129
 
130
+ // Where restoreReadingBlock last put a block, per scroller. scrollTop takes
131
+ // whole pixels, so the block lands up to half a pixel off the offset it was
132
+ // asked for. Rows are rebuilt twice in a row when a turn ends (the reply
133
+ // takes the live bubble's place, then the reload after the turn), and each
134
+ // capture would take the last landing as the truth: the halves added up to
135
+ // more than a pixel.
136
+ const lastRestored = new WeakMap();
137
+
138
+ // The offset the block was put at, while the reader is still there: the
139
+ // same block, scrollTop as it was left, and the block no further off than
140
+ // the rounding.
141
+ function intendedOffset(el, tag, text, measured) {
142
+ const restored = lastRestored.get(el);
143
+ if (!restored || restored.tag !== tag || restored.text !== text) return measured;
144
+ if (Number(el.scrollTop || 0) !== restored.scrollTop) return measured;
145
+ return Math.abs(measured - restored.offset) <= 0.5 ? restored.offset : measured;
146
+ }
147
+
130
148
  /**
131
149
  * The block of text the reader is at: the first one that starts inside the
132
150
  * viewport, else the one that reaches into it from above (a code block
@@ -167,7 +185,7 @@ export function captureReadingBlock(el, { scope = null } = {}) {
167
185
  const tag = String(block.tagName || '').toLowerCase();
168
186
  const text = blockText(block);
169
187
  const height = Math.max(0, rect.bottom - rect.top);
170
- const offset = rect.top - view.top;
188
+ const offset = intendedOffset(el, tag, text, rect.top - view.top);
171
189
  return {
172
190
  messageId: String(foundScope?.dataset?.messageId || '').trim() || null,
173
191
  // Read in the live bubble: the reply that replaces it is another row.
@@ -220,6 +238,7 @@ export function restoreReadingBlock(el, anchor, { scope = null } = {}) {
220
238
  : Number(anchor.offsetFraction) * Math.max(0, rect.bottom - rect.top);
221
239
  const delta = rect.top - view.top - target;
222
240
  if (Math.abs(delta) > 0.5) el.scrollTop = Number(el.scrollTop || 0) + delta;
241
+ lastRestored.set(el, { tag: anchor.tag, text: anchor.text, offset: target, scrollTop: Number(el.scrollTop || 0) });
223
242
  return true;
224
243
  }
225
244
 
@@ -280,6 +280,7 @@
280
280
  --warn: #d29922;
281
281
  --conversation-title-offline: #f0f6fc;
282
282
  --conversation-title-ready: #3fb950;
283
+ --conversation-title-idle: #238636;
283
284
  --conversation-title-question: #f85149;
284
285
  --conversation-title-error: #d29922;
285
286
  /* Relay reachability tones, shared by the sidebar dot and the mobile
@@ -347,6 +348,8 @@
347
348
  --warn: #9a6700;
348
349
  --conversation-title-offline: #1F2328;
349
350
  --conversation-title-ready: #1a7f37;
351
+ /* Muted, not darker: a darker green would stand out more on white. */
352
+ --conversation-title-idle: #4c8b5c;
350
353
  --conversation-title-question: #cf222e;
351
354
  --conversation-title-error: #9a6700;
352
355
  --provider-github: #0969da;
@@ -842,17 +845,17 @@
842
845
  .conv-provider-indicator[data-provider="claude-cloud"] { color: var(--provider-claude-cloud); }
843
846
  .conv-item.worker-ui-offline .conv-title,
844
847
  #chat-title[data-ui-state="offline"] { color: var(--conversation-title-offline); }
848
+ /* Ready and idle: the darker green. Ready and working (a turn or
849
+ background work): the bright one. :where() keeps the working rule as
850
+ specific as the others, so an archived row stays muted. */
845
851
  .conv-item.worker-ui-ready .conv-title,
846
- #chat-title[data-ui-state="ready"] { color: var(--conversation-title-ready); }
852
+ #chat-title[data-ui-state="ready"] { color: var(--conversation-title-idle); }
853
+ .conv-item.worker-ui-ready:where(.conv-working) .conv-title,
854
+ #chat-title[data-ui-state="ready"][data-working] { color: var(--conversation-title-ready); }
847
855
  .conv-item.worker-ui-question .conv-title,
848
856
  #chat-title[data-ui-state="question"] { color: var(--conversation-title-question); }
849
857
  .conv-item.worker-ui-error .conv-title,
850
858
  #chat-title[data-ui-state="error"] { color: var(--conversation-title-error); }
851
- .conv-processing-dots {
852
- display: inline-block;
853
- min-width: 1.8ch;
854
- letter-spacing: 0.02em;
855
- }
856
859
  .conv-delete {
857
860
  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
858
861
  background: none; border: none; color: var(--muted); cursor: pointer; font-size: var(--fs-ui-icon);
@@ -3379,6 +3382,166 @@
3379
3382
  .subagent-viewer-dialog { width: 100%; height: 100%; border: 0; border-radius: 0; }
3380
3383
  #subagent-viewer-body { padding: 8px 10px 28px; }
3381
3384
  }
3385
+ /* The task console: a shell or Monitor task's output (task-console.mjs).
3386
+ Same shell as the sub-agent viewer; the 16 terminal colours follow the
3387
+ theme so red and green stay readable on Day and Night. */
3388
+ :root {
3389
+ --tc-ansi-0: #484f58;
3390
+ --tc-ansi-1: #ff7b72;
3391
+ --tc-ansi-2: #3fb950;
3392
+ --tc-ansi-3: #d29922;
3393
+ --tc-ansi-4: #58a6ff;
3394
+ --tc-ansi-5: #bc8cff;
3395
+ --tc-ansi-6: #39c5cf;
3396
+ --tc-ansi-7: #b1bac4;
3397
+ --tc-ansi-8: #6e7681;
3398
+ --tc-ansi-9: #ffa198;
3399
+ --tc-ansi-10: #56d364;
3400
+ --tc-ansi-11: #e3b341;
3401
+ --tc-ansi-12: #79c0ff;
3402
+ --tc-ansi-13: #d2a8ff;
3403
+ --tc-ansi-14: #56d4dd;
3404
+ --tc-ansi-15: #f0f6fc;
3405
+ }
3406
+ :root[data-theme="light"] {
3407
+ --tc-ansi-0: #24292f;
3408
+ --tc-ansi-1: #cf222e;
3409
+ --tc-ansi-2: #116329;
3410
+ --tc-ansi-3: #4d2d00;
3411
+ --tc-ansi-4: #0969da;
3412
+ --tc-ansi-5: #8250df;
3413
+ --tc-ansi-6: #1b7c83;
3414
+ --tc-ansi-7: #6e7781;
3415
+ --tc-ansi-8: #57606a;
3416
+ --tc-ansi-9: #a40e26;
3417
+ --tc-ansi-10: #1a7f37;
3418
+ --tc-ansi-11: #633c01;
3419
+ --tc-ansi-12: #218bff;
3420
+ --tc-ansi-13: #a475f9;
3421
+ --tc-ansi-14: #3192aa;
3422
+ --tc-ansi-15: #8c959f;
3423
+ }
3424
+ #task-console-modal {
3425
+ position: fixed; inset: 0; z-index: 10900; display: none;
3426
+ align-items: center; justify-content: center;
3427
+ background: rgba(0, 0, 0, 0.68); padding: 18px;
3428
+ }
3429
+ #task-console-modal.visible { display: flex; }
3430
+ #task-console-title { flex: 1; min-width: 0; margin: 0; font-size: 1.05rem; font-weight: 600; overflow-wrap: anywhere; }
3431
+ .task-console-meta-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font-size: var(--fs-meta-sm); }
3432
+ #task-console-meta { overflow-wrap: anywhere; }
3433
+ .tc-stall { color: var(--muted); }
3434
+ .tc-stall-warn { color: var(--warn); font-weight: 600; }
3435
+ #task-console-chip { cursor: pointer; font: inherit; }
3436
+ #task-console-failure {
3437
+ background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer;
3438
+ font-size: var(--fs-meta-sm); text-decoration: underline; text-underline-offset: 3px;
3439
+ }
3440
+ #task-console-stop:hover:not(:disabled) { border-color: #f85149; color: #f85149; }
3441
+ #task-console-scroll {
3442
+ flex: 1; overflow: auto; overscroll-behavior: contain; padding: 8px 14px 28px;
3443
+ background: var(--bg);
3444
+ }
3445
+ #task-console-earlier {
3446
+ display: block; margin: 0 auto 8px; border: 1px solid var(--border); background: var(--bg2);
3447
+ color: var(--text); border-radius: 999px; padding: 3px 14px; font-size: var(--fs-meta-sm); cursor: pointer;
3448
+ }
3449
+ #task-console-earlier:disabled { opacity: 0.6; cursor: default; }
3450
+ #task-console-earlier[hidden] { display: none; }
3451
+ #task-console-body {
3452
+ margin: 0; font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
3453
+ font-size: var(--fs-meta-sm); line-height: 1.45; color: var(--text);
3454
+ white-space: pre-wrap; overflow-wrap: anywhere;
3455
+ }
3456
+ .tc-line { min-height: 1.45em; }
3457
+ .tc-line-partial { opacity: 0.85; }
3458
+ .tc-line-target { background: rgba(248, 81, 73, 0.16); border-radius: 3px; }
3459
+ .tc-line-count {
3460
+ display: inline-block; margin-left: 8px; padding: 0 6px; border-radius: 999px;
3461
+ border: 1px solid var(--border); color: var(--muted); font-size: 0.85em; user-select: none;
3462
+ }
3463
+ .tc-bold { font-weight: 700; }
3464
+ .tc-dim { opacity: 0.65; }
3465
+ .tc-italic { font-style: italic; }
3466
+ .tc-underline { text-decoration: underline; }
3467
+ .tc-fg-0 { color: var(--tc-ansi-0); }
3468
+ .tc-fg-1 { color: var(--tc-ansi-1); }
3469
+ .tc-fg-2 { color: var(--tc-ansi-2); }
3470
+ .tc-fg-3 { color: var(--tc-ansi-3); }
3471
+ .tc-fg-4 { color: var(--tc-ansi-4); }
3472
+ .tc-fg-5 { color: var(--tc-ansi-5); }
3473
+ .tc-fg-6 { color: var(--tc-ansi-6); }
3474
+ .tc-fg-7 { color: var(--tc-ansi-7); }
3475
+ .tc-fg-8 { color: var(--tc-ansi-8); }
3476
+ .tc-fg-9 { color: var(--tc-ansi-9); }
3477
+ .tc-fg-10 { color: var(--tc-ansi-10); }
3478
+ .tc-fg-11 { color: var(--tc-ansi-11); }
3479
+ .tc-fg-12 { color: var(--tc-ansi-12); }
3480
+ .tc-fg-13 { color: var(--tc-ansi-13); }
3481
+ .tc-fg-14 { color: var(--tc-ansi-14); }
3482
+ .tc-fg-15 { color: var(--tc-ansi-15); }
3483
+ .tc-bg-0 { background: var(--tc-ansi-0); }
3484
+ .tc-bg-1 { background: var(--tc-ansi-1); }
3485
+ .tc-bg-2 { background: var(--tc-ansi-2); }
3486
+ .tc-bg-3 { background: var(--tc-ansi-3); }
3487
+ .tc-bg-4 { background: var(--tc-ansi-4); }
3488
+ .tc-bg-5 { background: var(--tc-ansi-5); }
3489
+ .tc-bg-6 { background: var(--tc-ansi-6); }
3490
+ .tc-bg-7 { background: var(--tc-ansi-7); }
3491
+ .tc-bg-8 { background: var(--tc-ansi-8); }
3492
+ .tc-bg-9 { background: var(--tc-ansi-9); }
3493
+ .tc-bg-10 { background: var(--tc-ansi-10); }
3494
+ .tc-bg-11 { background: var(--tc-ansi-11); }
3495
+ .tc-bg-12 { background: var(--tc-ansi-12); }
3496
+ .tc-bg-13 { background: var(--tc-ansi-13); }
3497
+ .tc-bg-14 { background: var(--tc-ansi-14); }
3498
+ .tc-bg-15 { background: var(--tc-ansi-15); }
3499
+ #task-console-status { color: var(--muted); padding: 10px 0; font-size: var(--fs-meta-sm); }
3500
+ #task-console-status[hidden] { display: none; }
3501
+ #task-console-jump {
3502
+ position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
3503
+ border: 1px solid var(--accent); background: var(--bg3); color: var(--accent);
3504
+ border-radius: 999px; padding: 5px 14px; font-size: var(--fs-meta-sm); cursor: pointer;
3505
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
3506
+ }
3507
+ #task-console-jump[hidden] { display: none; }
3508
+ /* Rows in the composer panel: the console opener, the health hints, the
3509
+ ended rows. */
3510
+ .bg-task-desc.task-console-open {
3511
+ display: block; -webkit-line-clamp: unset; background: none; border: 0; padding: 0; margin: 0;
3512
+ font: inherit; color: var(--text); text-align: left; cursor: pointer;
3513
+ text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px;
3514
+ }
3515
+ .bg-task-desc.task-console-open:hover, .bg-task-desc.task-console-open:focus-visible {
3516
+ color: var(--accent); text-decoration-color: currentColor; outline: none;
3517
+ }
3518
+ .bg-task-desc-no-output { cursor: help; }
3519
+ .bg-task-steps { color: var(--muted); flex-shrink: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
3520
+ .bg-task-stall { color: var(--muted); flex-shrink: 0; white-space: nowrap; }
3521
+ .bg-task-stall[hidden] { display: none; }
3522
+ .bg-task-stall-warn { color: var(--warn); font-weight: 600; }
3523
+ .bg-task-chip {
3524
+ flex-shrink: 0; border: 1px solid var(--success); color: var(--success); background: transparent;
3525
+ border-radius: 999px; padding: 0 7px; font-size: var(--fs-meta-sm); white-space: nowrap;
3526
+ font-variant-numeric: tabular-nums;
3527
+ }
3528
+ .bg-task-chip[hidden] { display: none; }
3529
+ .bg-task-chip-fail { border-color: var(--danger); color: var(--danger); }
3530
+ .bg-task-row-ended { opacity: 0.62; }
3531
+ .bg-task-row-ended:hover, .bg-task-row-ended:focus-within { opacity: 0.9; }
3532
+ .bg-task-outcome { flex-shrink: 0; font-weight: 700; }
3533
+ .bg-task-outcome-ok { color: var(--success); }
3534
+ .bg-task-outcome-fail { color: var(--danger); }
3535
+ .bg-task-dismiss {
3536
+ flex-shrink: 0; background: transparent; border: 1px solid var(--border); color: var(--muted);
3537
+ border-radius: 8px; width: 26px; height: 24px; line-height: 1; cursor: pointer; font-size: 1rem;
3538
+ }
3539
+ .bg-task-dismiss:hover { color: var(--text); border-color: var(--text); }
3540
+ @media (max-width: 680px) {
3541
+ #task-console-modal { padding: 0; }
3542
+ #task-console-modal .subagent-viewer-dialog { width: 100%; height: 100%; border: 0; border-radius: 0; }
3543
+ #task-console-scroll { padding: 8px 10px 28px; }
3544
+ }
3382
3545
  /* Preview cards: same row grammar as a background task, with a link-shaped
3383
3546
  primary action. Shared by the composer panel and the settings list. */
3384
3547
  .preview-row {
@@ -7005,6 +7168,22 @@
7005
7168
  >
7006
7169
  </div>
7007
7170
  <div class="settings-help" style="margin-top: 6px;">A conversation only scrolls by itself while you are at its end. Anywhere else it stays where you are reading, and the round button above the composer shows that something new or a question is waiting below. Your own message is the exception: sending one takes you to the end. Switch this off to stay put then too. Saved in this browser.</div>
7171
+ <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
7172
+ <label for="conversation-window-retention-slider">Messages kept per conversation for switching back <span id="conversation-window-retention-value">200 messages</span></label>
7173
+ <input
7174
+ id="conversation-window-retention-slider"
7175
+ type="range"
7176
+ min="100"
7177
+ max="1100"
7178
+ step="100"
7179
+ value="200"
7180
+ aria-label="Messages kept per conversation for switching back"
7181
+ aria-valuetext="200 messages"
7182
+ oninput="previewConversationWindowRetention(this.value)"
7183
+ onchange="updateConversationWindowRetention(this.value)"
7184
+ >
7185
+ </div>
7186
+ <div class="settings-help" style="margin-top: 6px;">A conversation you switch away from and back to opens where you were reading, with the older messages you had loaded. This many are kept around that place for each of the last eight conversations; No limit keeps them all, at the cost of memory. Saved in this browser.</div>
7008
7187
  <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
7009
7188
  <label for="turn-ceiling-slider">Max turn duration <span id="turn-ceiling-value">No limit</span></label>
7010
7189
  <input
@@ -7035,6 +7214,21 @@
7035
7214
  >
7036
7215
  </div>
7037
7216
  <div class="settings-help" style="margin-top: 6px;">How long background tasks may keep a session alive after its turn ends: backgrounded commands, subagents and monitors on Claude; background agents and detached shells on Copilot's SDK engine. When the limit is reached the tasks are stopped. Set to 0 for no limit — tasks then run until they finish or you stop them from the composer panel.</div>
7217
+ <div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
7218
+ <label for="task-stall-minutes-slider">Mark a background task as quiet after <span id="task-stall-minutes-value">10 min</span></label>
7219
+ <input
7220
+ id="task-stall-minutes-slider"
7221
+ type="range"
7222
+ min="1"
7223
+ max="120"
7224
+ step="1"
7225
+ value="10"
7226
+ aria-label="Minutes without output before a background task is marked as quiet"
7227
+ oninput="previewTaskStallMinutesSetting(this.value)"
7228
+ onchange="updateTaskStallMinutesSetting(this.value)"
7229
+ >
7230
+ </div>
7231
+ <div class="settings-help" style="margin-top: 6px;">A shell or Monitor task that has written no output for this long gets an amber "no output for N min" note in the composer panel and its console.</div>
7038
7232
  <div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
7039
7233
  <label for="subagent-record-retention-select">Keep sub-agent tool output</label>
7040
7234
  <select id="subagent-record-retention-select" onchange="updateSubagentRecordRetentionSetting(this.value)" aria-label="Keep sub-agent tool output">
@@ -7372,6 +7566,18 @@
7372
7566
  <option value="off">Off — no attribution</option>
7373
7567
  </select>
7374
7568
  <div id="claude-attribution-example" class="settings-help"></div>
7569
+ <label for="claude-background-command-limit-slider" style="margin-top: 8px;">Claude background command limit <span id="claude-background-command-limit-value">2 h</span></label>
7570
+ <input
7571
+ id="claude-background-command-limit-slider"
7572
+ type="range"
7573
+ min="30"
7574
+ max="480"
7575
+ step="30"
7576
+ value="120"
7577
+ aria-label="Claude background command limit in minutes"
7578
+ oninput="window.previewClaudeBackgroundCommandLimit?.(this.value)"
7579
+ >
7580
+ <div class="settings-help">How long a command Claude runs in the background may take before Claude Code stops it, unless Claude gives it its own timeout (up to 8 h). Commands in the foreground move to the background after 2 min. Applies to Claude sessions started after you save; a running session keeps its limit until it restarts.</div>
7375
7581
  <div id="claude-settings-status" class="settings-help settings-provider-status">Loading Claude settings…</div>
7376
7582
  <div style="display: flex; gap: 8px; margin-top: 8px;">
7377
7583
  <button id="claude-save-btn" type="button" class="summary-btn" onclick="saveClaudeSettings()">Save Claude settings</button>
@@ -8029,6 +8235,33 @@
8029
8235
  </div>
8030
8236
  </div>
8031
8237
 
8238
+ <!-- Task console: a shell or Monitor task's output, live while it runs
8239
+ (task-console.mjs). -->
8240
+ <div id="task-console-modal" aria-hidden="true">
8241
+ <div class="subagent-viewer-dialog task-console-dialog" role="dialog" aria-modal="true" aria-labelledby="task-console-title">
8242
+ <div class="subagent-viewer-header">
8243
+ <div class="subagent-viewer-title-row">
8244
+ <span aria-hidden="true">⌨️</span>
8245
+ <h2 id="task-console-title">Task</h2>
8246
+ <button id="task-console-stop" class="subagent-viewer-btn" type="button" hidden>Stop</button>
8247
+ <button class="subagent-viewer-btn task-console-close" type="button" aria-label="Close">×</button>
8248
+ </div>
8249
+ <div class="task-console-meta-row">
8250
+ <span id="task-console-meta"></span>
8251
+ <span id="task-console-stall" class="tc-stall" hidden></span>
8252
+ <button id="task-console-chip" class="bg-task-chip" type="button" hidden></button>
8253
+ <button id="task-console-failure" type="button" hidden>first failure</button>
8254
+ </div>
8255
+ </div>
8256
+ <div id="task-console-scroll">
8257
+ <button id="task-console-earlier" type="button" hidden>Load earlier</button>
8258
+ <pre id="task-console-body"></pre>
8259
+ <div id="task-console-status" hidden></div>
8260
+ </div>
8261
+ <button id="task-console-jump" type="button" hidden>Jump to end ↓</button>
8262
+ </div>
8263
+ </div>
8264
+
8032
8265
  <!-- Fullscreen file viewer. The content fills the screen; the chrome bars
8033
8266
  float over it and a tap on the content shows or hides them. -->
8034
8267
  <div id="file-preview-modal" aria-hidden="true">
@@ -42,5 +42,6 @@ This relay itself is listed with "self": true when its owner switched agent sess
42
42
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
43
43
  - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
44
44
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
45
+ - `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
45
46
 
46
47
  Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
4
  import { createMessagePinStatements } from './message-pin-statements.mjs';
5
+ import { createConversationHistoryPageStatements } from '../services/conversation-history-page.mjs';
5
6
  import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
6
7
 
7
8
  export function createMessageRepository(db) {
@@ -27,6 +28,8 @@ export function createMessageRepository(db) {
27
28
  // messages
28
29
  getMessages: db.prepare(`SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
29
30
  getSharedMessages: shareVisibility.getSharedMessages,
31
+ // One history page in SQL (conversation-history-page.mjs).
32
+ conversationHistoryPage: createConversationHistoryPageStatements(db),
30
33
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
31
34
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
32
35
  listHiddenMessages: shareVisibility.listHiddenMessages,
@@ -116,6 +119,11 @@ export function createMessageRepository(db) {
116
119
  ORDER BY captured_at DESC
117
120
  LIMIT 1
118
121
  `),
122
+ getMessageUsageSnapshotByResponse: db.prepare(`
123
+ SELECT *
124
+ FROM message_usage_snapshots
125
+ WHERE response_message_id = ? AND conversation_id = ?
126
+ `),
119
127
  listMessageUsageSnapshotsByConversation: db.prepare(`
120
128
  SELECT *
121
129
  FROM message_usage_snapshots