@oar-sh/oar 0.9.10 → 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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  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 +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -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/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,737 @@
1
+ // The text pipeline of a background task's console output: what the relay
2
+ // does to a shell or Monitor task's log before anyone sees it, and the
3
+ // pieces a viewer needs to show it.
4
+ //
5
+ // - stripControlKeepSgr: terminal control codes out, colours (SGR) kept.
6
+ // - createLineFolder: a stream fed in arbitrary pieces becomes lines; a `\r`
7
+ // redraw (progress bars) collapses to its final state, adjacent identical
8
+ // lines fold into one with a count, and a line is cut at 4,000 characters.
9
+ // - ansiToSegments: one line as styled segments, 16 colours.
10
+ // - scrubTaskOutput / scrubSecrets: credentials out before output leaves the
11
+ // relay (the page, a paired relay's agent).
12
+ // - parseRunnerSummary: pass/fail counts of the common test runners.
13
+ // - stallMinutes and the stall setting's bounds.
14
+ //
15
+ // Pure ESM with no Node imports: the relay uses it for the REST tail, the
16
+ // health poller and the remote action. The browser cannot import shared/
17
+ // (only server/public is served); server/public/app/task-output-text.mjs keeps
18
+ // a copy of the folder, the segments and the stall minutes, and its test pins
19
+ // it to this module.
20
+
21
+ // ─── Limits ──────────────────────────────────────────────────────────────────
22
+
23
+ export const TASK_OUTPUT_LIMITS = Object.freeze({
24
+ // Initial tail: this many lines or this many bytes, whichever comes first.
25
+ tailLinesDefault: 2000,
26
+ tailLinesMax: 5000,
27
+ requestBytesMax: 256 * 1024,
28
+ // What a modal loads in total, paging back.
29
+ modalBytesMax: 5 * 1024 * 1024,
30
+ // One live emit, and the window the health poller parses.
31
+ chunkBytes: 64 * 1024,
32
+ healthBytes: 64 * 1024,
33
+ lineCharsMax: 4000,
34
+ outputPathMax: 1024,
35
+ // Ended rows: kept this long, at most this many per conversation.
36
+ endedKeepMs: 60 * 60 * 1000,
37
+ endedMax: 20,
38
+ // The worker's spool stops appending here.
39
+ spoolBytesMax: 50 * 1024 * 1024,
40
+ // The remote action's tail.
41
+ remoteLinesDefault: 200,
42
+ remoteLinesMax: 500,
43
+ remoteBytesMax: 32 * 1024,
44
+ });
45
+
46
+ export const TASK_OUTPUT_KINDS = Object.freeze(['file', 'spool', 'snapshot', 'none']);
47
+
48
+ /** The kind a task row's output has: what it names, else `file` with a path and `none` without. */
49
+ export function normalizeTaskOutputKind(kind, outputPath) {
50
+ const text = String(kind ?? '').trim().toLowerCase();
51
+ if (TASK_OUTPUT_KINDS.includes(text)) return text;
52
+ return outputPath ? 'file' : 'none';
53
+ }
54
+
55
+ /**
56
+ * The path a worker names for a task's output, or '' when it is not one the
57
+ * relay reads: absolute (POSIX or a Windows drive / UNC path), at most 1,024
58
+ * characters, no NUL. Pure string checks; the relay's reader also refuses
59
+ * symlinks and anything that is not a regular file.
60
+ */
61
+ export function normalizeTaskOutputPath(value) {
62
+ const text = typeof value === 'string' ? value.trim() : '';
63
+ if (!text || text.length > TASK_OUTPUT_LIMITS.outputPathMax || text.includes('\0')) return '';
64
+ const absolute = text.startsWith('/') || /^[A-Za-z]:[\\/]/.test(text) || text.startsWith('\\\\');
65
+ return absolute ? text : '';
66
+ }
67
+
68
+ // ─── The stall setting ───────────────────────────────────────────────────────
69
+
70
+ // "Mark a background task as quiet after N minutes" (app_settings key
71
+ // task_stall_minutes).
72
+ export const TASK_STALL_MINUTES_MIN = 1;
73
+ export const TASK_STALL_MINUTES_MAX = 120;
74
+ export const DEFAULT_TASK_STALL_MINUTES = 10;
75
+ export const TASK_STALL_MINUTES_SETTING_KEY = 'task_stall_minutes';
76
+
77
+ export function normalizeTaskStallMinutes(value, fallback = DEFAULT_TASK_STALL_MINUTES) {
78
+ if (value === null || value === undefined) return fallback;
79
+ if (typeof value === 'string' && value.trim() === '') return fallback;
80
+ const number = Number(value);
81
+ if (!Number.isFinite(number)) return fallback;
82
+ return Math.min(TASK_STALL_MINUTES_MAX, Math.max(TASK_STALL_MINUTES_MIN, Math.round(number)));
83
+ }
84
+
85
+ /** An unset or unreadable setting is the default. */
86
+ export function readTaskStallMinutesSetting(storedValue) {
87
+ return normalizeTaskStallMinutes(storedValue);
88
+ }
89
+
90
+ export function parseTaskStallMinutesUpdate(value) {
91
+ const minutes = normalizeTaskStallMinutes(value, null);
92
+ if (minutes === null) return { ok: false, error: 'minutes must be a number' };
93
+ return { ok: true, minutes };
94
+ }
95
+
96
+ function toMillis(value) {
97
+ if (value == null || value === '') return NaN;
98
+ const number = Number(value);
99
+ if (Number.isFinite(number)) return number;
100
+ return Date.parse(String(value));
101
+ }
102
+
103
+ /** Whole minutes since the last output, or null when unknown. */
104
+ export function stallMinutes(lastOutputAt, now = Date.now()) {
105
+ const last = toMillis(lastOutputAt);
106
+ const current = toMillis(now);
107
+ if (!Number.isFinite(last) || !Number.isFinite(current) || last <= 0) return null;
108
+ return Math.max(0, Math.floor((current - last) / 60_000));
109
+ }
110
+
111
+ // ─── Control codes ───────────────────────────────────────────────────────────
112
+
113
+ // SGR (colours and text style) is the only escape kept. Its parameters are
114
+ // digits and semicolons; the colon form is rare and goes with the rest.
115
+ const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
116
+ // OSC (titles, hyperlinks, clipboard): ESC ] … BEL | ST, stripped whole. An
117
+ // unterminated one at the end is stripped to the end.
118
+ const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
119
+ // Every CSI (cursor moves, erases, modes) that is not SGR.
120
+ const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
121
+ // Character-set designations (ESC ( B) and the remaining two-byte escapes.
122
+ const CHARSET_PATTERN = /\x1b[()*+\-./][0-~]/g;
123
+ // The remaining two-byte escapes (ESC 7, ESC =, ESC M, a CSI cut short) and
124
+ // a lone ESC; never the start of a kept SGR.
125
+ const OTHER_ESCAPE_PATTERN = /\x1b(?!\[[0-9;]*m)[0-~]?/g;
126
+ // C0 except \t \n \r, DEL, C1, and a lone ESC left over.
127
+ const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;
128
+
129
+ /**
130
+ * Drops OSC, every CSI but SGR, the other escapes, C1 and C0 controls except
131
+ * `\n`, `\r` and `\t`; keeps `ESC [ … m`.
132
+ */
133
+ export function stripControlKeepSgr(text) {
134
+ const value = String(text ?? '');
135
+ if (!/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/.test(value)) return value;
136
+ return value
137
+ .replace(OSC_PATTERN, '')
138
+ .replace(CSI_PATTERN, (match) => (/^\x1b\[[0-9;]*m$/.test(match) ? match : ''))
139
+ .replace(CHARSET_PATTERN, '')
140
+ .replace(OTHER_ESCAPE_PATTERN, '')
141
+ .replace(CONTROL_PATTERN, '');
142
+ }
143
+
144
+ /** The text without its SGR codes: what a reader without colours sees. */
145
+ export function stripSgr(text) {
146
+ return String(text ?? '').replace(SGR_PATTERN, '');
147
+ }
148
+
149
+ // ─── Lines ───────────────────────────────────────────────────────────────────
150
+
151
+ function cutLine(text) {
152
+ return text.length > TASK_OUTPUT_LIMITS.lineCharsMax ? text.slice(0, TASK_OUTPUT_LIMITS.lineCharsMax) : text;
153
+ }
154
+
155
+ // A line's final state: a `\r` not followed by `\n` discards what came before
156
+ // it (a trailing one still waits for what follows).
157
+ function finalLineState(raw) {
158
+ const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
159
+ const at = body.lastIndexOf('\r');
160
+ return at === -1 ? body : body.slice(at + 1);
161
+ }
162
+
163
+ /**
164
+ * Lines from a stream fed in arbitrary pieces. `feed(text)` returns
165
+ * `{ lines, partial, changedFrom }`: every completed line so far as
166
+ * `{ text, count }` (adjacent identical lines folded, `count` unbounded), the
167
+ * open partial line in its current state ('' when there is none), and the
168
+ * index of the first line this feed added or changed (a folded repeat bumps
169
+ * the count of the line before). `end()` closes the partial line and returns
170
+ * the same shape. `lines` is the folder's own array: copy it to keep it.
171
+ */
172
+ export function createLineFolder() {
173
+ const lines = [];
174
+ let partial = '';
175
+ let changedFrom = 0;
176
+
177
+ function push(text) {
178
+ const line = cutLine(text);
179
+ const last = lines[lines.length - 1];
180
+ if (last && last.text === line) {
181
+ last.count += 1;
182
+ changedFrom = Math.min(changedFrom, lines.length - 1);
183
+ } else {
184
+ changedFrom = Math.min(changedFrom, lines.length);
185
+ lines.push({ text: line, count: 1 });
186
+ }
187
+ }
188
+
189
+ function result() {
190
+ const out = { lines, partial: finalLineState(partial), changedFrom };
191
+ changedFrom = lines.length;
192
+ return out;
193
+ }
194
+
195
+ return {
196
+ feed(text) {
197
+ const chunk = String(text ?? '');
198
+ if (chunk) {
199
+ const parts = (partial + chunk).split('\n');
200
+ partial = parts.pop();
201
+ for (const raw of parts) push(finalLineState(raw));
202
+ // The open line stays small: a progress bar redraws itself
203
+ // thousands of times on one line.
204
+ const keepCr = partial.endsWith('\r');
205
+ partial = cutLine(finalLineState(partial)) + (keepCr ? '\r' : '');
206
+ }
207
+ return result();
208
+ },
209
+ end() {
210
+ const last = finalLineState(partial);
211
+ partial = '';
212
+ if (last) push(last);
213
+ return result();
214
+ },
215
+ get lines() {
216
+ return lines;
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * A whole text as the line model the console shows: `{ text, count, ansi }`
223
+ * per line (`ansi` when it carries colour codes), the last one marked
224
+ * `partial` when the text does not end with a newline.
225
+ */
226
+ export function foldTaskOutput(text) {
227
+ const folder = createLineFolder();
228
+ const { lines, partial } = folder.feed(text);
229
+ const model = lines.map((line) => ({ text: line.text, count: line.count, ansi: line.text.includes('\x1b') }));
230
+ if (partial) model.push({ text: partial, count: 1, ansi: partial.includes('\x1b'), partial: true });
231
+ return model;
232
+ }
233
+
234
+ // ─── Colours ─────────────────────────────────────────────────────────────────
235
+
236
+ // The 16-colour palette (xterm defaults), for mapping 256 and truecolor.
237
+ const PALETTE_16 = [
238
+ [0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
239
+ [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
240
+ ];
241
+
242
+ function nearest16([r, g, b]) {
243
+ let best = 0;
244
+ let bestDistance = Infinity;
245
+ PALETTE_16.forEach(([pr, pg, pb], index) => {
246
+ const distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
247
+ if (distance < bestDistance) {
248
+ best = index;
249
+ bestDistance = distance;
250
+ }
251
+ });
252
+ return best;
253
+ }
254
+
255
+ function rgbOf256(n) {
256
+ if (n < 16) return PALETTE_16[n];
257
+ if (n >= 232) {
258
+ const level = 8 + (n - 232) * 10;
259
+ return [level, level, level];
260
+ }
261
+ const index = n - 16;
262
+ const steps = [0, 95, 135, 175, 215, 255];
263
+ return [steps[Math.floor(index / 36)], steps[Math.floor(index / 6) % 6], steps[index % 6]];
264
+ }
265
+
266
+ function clampByte(value) {
267
+ const number = Number(value);
268
+ return Number.isFinite(number) ? Math.min(255, Math.max(0, number)) : 0;
269
+ }
270
+
271
+ function plainStyle() {
272
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
273
+ }
274
+
275
+ function applySgr(state, params) {
276
+ const codes = params === '' ? [0] : params.split(';').map((part) => (part === '' ? 0 : Number(part)));
277
+ for (let i = 0; i < codes.length; i += 1) {
278
+ const code = codes[i];
279
+ if (code === 0) Object.assign(state, plainStyle());
280
+ else if (code === 1) state.bold = true;
281
+ else if (code === 2) state.dim = true;
282
+ else if (code === 3) state.italic = true;
283
+ else if (code === 4) state.underline = true;
284
+ else if (code === 22) {
285
+ state.bold = false;
286
+ state.dim = false;
287
+ } else if (code === 23) state.italic = false;
288
+ else if (code === 24) state.underline = false;
289
+ else if (code >= 30 && code <= 37) state.fg = code - 30;
290
+ else if (code === 39) state.fg = null;
291
+ else if (code >= 40 && code <= 47) state.bg = code - 40;
292
+ else if (code === 49) state.bg = null;
293
+ else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
294
+ else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
295
+ else if (code === 38 || code === 48) {
296
+ const key = code === 38 ? 'fg' : 'bg';
297
+ if (codes[i + 1] === 5 && i + 2 < codes.length) {
298
+ state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
299
+ i += 2;
300
+ } else if (codes[i + 1] === 2 && i + 4 < codes.length) {
301
+ state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
302
+ i += 4;
303
+ } else {
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
312
+ * colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
313
+ * Adjacent segments never share a style. Any escape that is not SGR is
314
+ * dropped from the text.
315
+ */
316
+ export function ansiToSegments(lineText) {
317
+ const text = String(lineText ?? '');
318
+ const state = plainStyle();
319
+ const segments = [];
320
+ const pushText = (value) => {
321
+ const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
322
+ if (!clean) return;
323
+ const last = segments[segments.length - 1];
324
+ if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
325
+ && last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
326
+ last.text += clean;
327
+ return;
328
+ }
329
+ segments.push({ text: clean, ...state });
330
+ };
331
+ let at = 0;
332
+ const pattern = new RegExp(SGR_PATTERN.source, 'g');
333
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
334
+ pushText(text.slice(at, match.index));
335
+ applySgr(state, match[1]);
336
+ at = match.index + match[0].length;
337
+ }
338
+ pushText(text.slice(at));
339
+ return segments;
340
+ }
341
+
342
+ // ─── Secrets ─────────────────────────────────────────────────────────────────
343
+
344
+ const REDACTED = '[redacted]';
345
+
346
+ // The generic rule of the CLI helpers (services/cli-process-runner.mjs):
347
+ // anything token-shaped. Right for a login's error tail, too eager for a
348
+ // test log (long snake_case test names, 40-character commit ids).
349
+ const CLI_TOKEN_PATTERNS = Object.freeze([
350
+ /sk-ant-[A-Za-z0-9_-]+/g,
351
+ /[A-Za-z0-9_-]{32,}/g,
352
+ ]);
353
+
354
+ /**
355
+ * Redacts anything token-shaped plus an exact secret the caller knows it
356
+ * handed over (the pasted Claude login code), so an error tail can be
357
+ * surfaced to the UI without leaking a credential. Long PKCE segments in an
358
+ * authorize URL get caught by the generic rule too, which is fine: the URL is
359
+ * already carried in the login payload. (Moved here from
360
+ * services/cli-process-runner.mjs, which re-exports it.)
361
+ */
362
+ export function scrubSecrets(text, submittedCode = '') {
363
+ let output = String(text == null ? '' : text);
364
+ const code = String(submittedCode == null ? '' : submittedCode).trim();
365
+ if (code.length >= 4) {
366
+ output = output.split(code).join(REDACTED);
367
+ }
368
+ for (const pattern of CLI_TOKEN_PATTERNS) output = output.replace(pattern, REDACTED);
369
+ return output;
370
+ }
371
+
372
+ // Known credential shapes. Each keeps its prefix so a reader still sees what
373
+ // kind of value was there.
374
+ const KNOWN_TOKEN_RULES = Object.freeze([
375
+ // Anthropic, OpenAI and other `sk-` keys.
376
+ [/\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, 'sk-[redacted]'],
377
+ // GitHub tokens (classic and fine-grained).
378
+ [/\b(gh[pousr]_)[A-Za-z0-9]{20,}/g, '$1[redacted]'],
379
+ [/\b(github_pat_)[A-Za-z0-9_]{20,}/g, '$1[redacted]'],
380
+ // Slack.
381
+ [/\b(xox[abposr]-)[A-Za-z0-9-]{10,}/g, '$1[redacted]'],
382
+ // npm.
383
+ [/\b(npm_)[A-Za-z0-9]{30,}/g, '$1[redacted]'],
384
+ // AWS access key ids.
385
+ [/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, '$1[redacted]'],
386
+ // Google API keys.
387
+ [/\b(AIza)[0-9A-Za-z_-]{30,}/g, '$1[redacted]'],
388
+ // Bearer / Basic credentials in a header or a curl line.
389
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, '$1 [redacted]'],
390
+ // A password in a URL: scheme://user:password@host.
391
+ [/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1[redacted]@'],
392
+ ]);
393
+
394
+ // NAME=value with a secret-sounding environment name (API_TOKEN=…,
395
+ // DB_PASSWORD="…"); the name stays. A value without a letter (a count, a
396
+ // port) or a placeholder (true, null, ***) is no secret.
397
+ const ENV_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?!OR))[A-Za-z0-9_]*)=(["']?)(?!\[redacted\]|(?:true|false|null|none|undefined)\b|\*+)(?=[^\s"'`]*[A-Za-z])([^\s"'`]{4,})/gi;
398
+ // `token: value`, `"password": "value"`, `api_key = value`, Authorization: …
399
+ // A Bearer/Basic value was handled above and keeps its scheme word.
400
+ const KEY_VALUE_PATTERN = /\b(token|secret|password|passwd|passphrase|api[_-]?key|access[_-]?key|client[_-]?secret|authorization)(["']?\s*[:=]\s*["']?)(?!(?:Bearer|Basic)\b|\[redacted\]|(?:true|false|null|none|undefined)\b|\*+)(?=[^\s"',;]*[A-Za-z])([^\s"',;]{4,})/gi;
401
+ // Anything else token-shaped: 32 or more of [A-Za-z0-9_-] with an upper-case
402
+ // letter, a lower-case letter and a digit. Random keys have all three; test
403
+ // names, paths and hex commit ids rarely do.
404
+ const GENERIC_TOKEN_PATTERN = /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])/g;
405
+ // A PEM private key block, whole.
406
+ const PEM_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g;
407
+
408
+ function scrubPlain(text) {
409
+ let output = text.replace(PEM_PATTERN, '[redacted private key]');
410
+ for (const [pattern, replacement] of KNOWN_TOKEN_RULES) output = output.replace(pattern, replacement);
411
+ output = output
412
+ .replace(ENV_ASSIGNMENT_PATTERN, (_match, name, quote) => `${name}=${quote}${REDACTED}`)
413
+ .replace(KEY_VALUE_PATTERN, (_match, key, separator) => `${key}${separator}${REDACTED}`)
414
+ .replace(GENERIC_TOKEN_PATTERN, REDACTED);
415
+ return output;
416
+ }
417
+
418
+ /**
419
+ * Credentials out of a task's output: the known token shapes, secret-named
420
+ * `KEY=value` and `key: value` pairs, Bearer/Basic values, URL passwords,
421
+ * private keys, and long random-looking tokens. SGR codes are left alone
422
+ * (the text between them is scrubbed), so colours survive.
423
+ */
424
+ export function scrubTaskOutput(text) {
425
+ const value = String(text ?? '');
426
+ if (!value) return value;
427
+ if (!value.includes('\x1b')) return scrubPlain(value);
428
+ return value.split(/(\x1b\[[0-9;]*m)/).map((part, index) => (index % 2 === 1 ? part : scrubPlain(part))).join('');
429
+ }
430
+
431
+ /** What leaves the relay: control codes out (SGR kept), then credentials out. */
432
+ export function cleanTaskOutput(text) {
433
+ return scrubTaskOutput(stripControlKeepSgr(text));
434
+ }
435
+
436
+ // ─── Test runners ────────────────────────────────────────────────────────────
437
+
438
+ const PASS_MARK = /^\s*(?:[✓✔√]|\[PASS\])\s+/;
439
+ const FAIL_MARK = /^\s*(?:[✗✘×✕✖]|\[FAIL\])\s+/;
440
+
441
+ function lineTextOf(line) {
442
+ const raw = line && typeof line === 'object' ? line.text : line;
443
+ return stripSgr(String(raw ?? '')).replace(/\r/g, '');
444
+ }
445
+
446
+ function countOf(line) {
447
+ const count = line && typeof line === 'object' ? Number(line.count) : 1;
448
+ return Number.isInteger(count) && count > 0 ? count : 1;
449
+ }
450
+
451
+ function countsFrom(text) {
452
+ const counts = {};
453
+ for (const match of text.matchAll(/(\d+)\s+(passed|failed|skipped|todo|pending|flaky|errors?|xfailed|xpassed|deselected|warnings?|interrupted|did not run)\b/gi)) {
454
+ counts[match[2].toLowerCase()] = (counts[match[2].toLowerCase()] || 0) + Number(match[1]);
455
+ }
456
+ return counts;
457
+ }
458
+
459
+ function summary(runner, { passed = 0, failed = 0, skipped = 0, flaky = 0 } = {}, extra = {}) {
460
+ return {
461
+ runner,
462
+ passed,
463
+ failed,
464
+ skipped,
465
+ ...(flaky ? { flaky } : {}),
466
+ ...extra,
467
+ };
468
+ }
469
+
470
+ // Final summaries, checked from the end of the log back. Each returns the
471
+ // counts or null.
472
+ const FINAL_SUMMARIES = [
473
+ // vitest: " Tests 2 failed | 412 passed | 3 skipped (417)"
474
+ {
475
+ runner: 'vitest',
476
+ match(texts, index) {
477
+ const text = texts[index];
478
+ if (!/^\s*Tests\s{2,}.*\(\d+\)\s*$/.test(text) || /^\s*Tests:/.test(text)) return null;
479
+ const counts = countsFrom(text);
480
+ return summary('vitest', {
481
+ passed: counts.passed || 0,
482
+ failed: counts.failed || 0,
483
+ skipped: (counts.skipped || 0) + (counts.todo || 0),
484
+ });
485
+ },
486
+ },
487
+ // jest: "Tests: 2 failed, 3 skipped, 412 passed, 417 total"
488
+ {
489
+ runner: 'jest',
490
+ match(texts, index) {
491
+ const text = texts[index];
492
+ if (!/^\s*Tests:\s+.*\b\d+\s+total\s*$/.test(text)) return null;
493
+ const counts = countsFrom(text);
494
+ return summary('jest', {
495
+ passed: counts.passed || 0,
496
+ failed: counts.failed || 0,
497
+ skipped: (counts.skipped || 0) + (counts.todo || 0) + (counts.pending || 0),
498
+ });
499
+ },
500
+ },
501
+ // pytest: "======= 2 failed, 412 passed, 3 skipped in 3.21s ======="
502
+ {
503
+ runner: 'pytest',
504
+ match(texts, index) {
505
+ const text = texts[index];
506
+ if (!/^=+ .*\bin [\d.]+s(?: \([^)]*\))? =+\s*$/.test(text)) return null;
507
+ const counts = countsFrom(text);
508
+ if (!Object.keys(counts).length && !/no tests ran/.test(text)) return null;
509
+ return summary('pytest', {
510
+ passed: (counts.passed || 0) + (counts.xpassed || 0),
511
+ failed: (counts.failed || 0) + (counts.error || 0) + (counts.errors || 0),
512
+ skipped: (counts.skipped || 0) + (counts.xfailed || 0) + (counts.deselected || 0),
513
+ });
514
+ },
515
+ },
516
+ // node:test, spec reporter ("ℹ pass 1") and TAP ("# pass 1"): the block
517
+ // ends with duration_ms.
518
+ {
519
+ runner: 'node:test',
520
+ match(texts, index) {
521
+ if (!/^\s*(?:ℹ|#)\s+duration_ms\s+[\d.]+\s*$/.test(texts[index])) return null;
522
+ const counts = {};
523
+ for (let i = index - 1; i >= 0 && i >= index - 12; i -= 1) {
524
+ const field = /^\s*(?:ℹ|#)\s+(tests|suites|pass|fail|cancelled|skipped|todo)\s+(\d+)\s*$/.exec(texts[i]);
525
+ if (!field) break;
526
+ counts[field[1]] = Number(field[2]);
527
+ }
528
+ if (counts.pass === undefined && counts.fail === undefined) return null;
529
+ return summary('node:test', {
530
+ passed: counts.pass || 0,
531
+ failed: (counts.fail || 0) + (counts.cancelled || 0),
532
+ skipped: (counts.skipped || 0) + (counts.todo || 0),
533
+ });
534
+ },
535
+ },
536
+ // Playwright: " 1 failed" … " 3 skipped" … " 412 passed (1.4m)": the
537
+ // block's lines sit together at the end, the passed line carries the time.
538
+ {
539
+ runner: 'playwright',
540
+ match(texts, index) {
541
+ const text = texts[index];
542
+ const own = /^\s{2}(\d+) (passed|failed|flaky|skipped|interrupted|did not run)(?: \(([\dhms.\s]+)\))?\s*$/.exec(text);
543
+ if (!own) return null;
544
+ // Walk up over the block: count lines, and the indented test titles
545
+ // listed under "failed" / "flaky".
546
+ const counts = {};
547
+ let timed = !!own[3];
548
+ let i = index;
549
+ for (; i >= 0; i -= 1) {
550
+ const line = texts[i];
551
+ const head = /^\s{2}(\d+) (passed|failed|flaky|skipped|interrupted|did not run)(?: \(([\dhms.\s]+)\))?\s*$/.exec(line);
552
+ if (head) {
553
+ counts[head[2]] = Number(head[1]);
554
+ if (head[3]) timed = true;
555
+ continue;
556
+ }
557
+ if (/^\s{4}\S/.test(line)) continue;
558
+ break;
559
+ }
560
+ if (!timed) return null;
561
+ return summary('playwright', {
562
+ passed: counts.passed || 0,
563
+ failed: (counts.failed || 0) + (counts.interrupted || 0),
564
+ skipped: (counts.skipped || 0) + (counts['did not run'] || 0),
565
+ flaky: counts.flaky || 0,
566
+ });
567
+ },
568
+ },
569
+ // turbo (also run through pnpm): " Tasks: 3 successful, 4 total", with a
570
+ // " Failed: pkg#task" line when one failed.
571
+ {
572
+ runner: 'turbo',
573
+ match(texts, index) {
574
+ const text = texts[index];
575
+ const head = /^\s*Tasks:\s+(\d+) successful(?:, (\d+) failed)?, (\d+) total\s*$/.exec(text);
576
+ if (!head) return null;
577
+ const successful = Number(head[1]);
578
+ const total = Number(head[3]);
579
+ const failed = head[2] !== undefined ? Number(head[2]) : Math.max(0, total - successful);
580
+ return summary('turbo', { passed: successful, failed });
581
+ },
582
+ },
583
+ ];
584
+
585
+ // What a log looks like while a run is still going: the runner's own header
586
+ // lines. Markers alone (✓ …) say nothing, a build log has them too.
587
+ const RUNNER_HEADERS = [
588
+ ['playwright', /^Running \d+ tests? using \d+ workers?/],
589
+ ['vitest', /^\s*(?:RUN|DEV)\s+v\d+\.\d+/],
590
+ ['jest', /^\s*(?:PASS|FAIL)\s+\S+\.(?:[cm]?[jt]sx?)\b/],
591
+ ['pytest', /^=+ test session starts =+$/],
592
+ ['node:test', /^(?:TAP version \d+|[✔✖] .+ \([\d.]+ms\)\s*$)/],
593
+ ['tap', /^(?:not )?ok \d+ - /],
594
+ ['turbo', /^\s*•\s+Running \S+ in \d+ packages?/],
595
+ ];
596
+
597
+ function liveCounts(runner, texts) {
598
+ let passed = 0;
599
+ let failed = 0;
600
+ let skipped = 0;
601
+ let firstFailureLine = null;
602
+ const fail = (index, amount = 1) => {
603
+ failed += amount;
604
+ if (firstFailureLine === null) firstFailureLine = index;
605
+ };
606
+ texts.forEach((text, index) => {
607
+ const repeat = texts.counts[index];
608
+ if (runner === 'pytest') {
609
+ const verbose = /\s(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)(?:\s+\[\s*\d+%\])?\s*$/.exec(text);
610
+ if (verbose) {
611
+ if (verbose[1] === 'PASSED' || verbose[1] === 'XPASS') passed += repeat;
612
+ else if (verbose[1] === 'FAILED' || verbose[1] === 'ERROR') fail(index, repeat);
613
+ else skipped += repeat;
614
+ return;
615
+ }
616
+ // Progress lines: "tests/test_a.py ..F.s [ 40%]"
617
+ // and their wrapped continuations ("........ [ 56%]").
618
+ const dots = /^\S+\.py\s+([.FEsxX]+)(?:\s+\[\s*\d+%\])?\s*$/.exec(text)
619
+ || /^([.FEsxX]+)\s*\[\s*\d+%\]\s*$/.exec(text);
620
+ if (dots) {
621
+ for (const mark of dots[1]) {
622
+ if (mark === '.' || mark === 'X') passed += 1;
623
+ else if (mark === 'F' || mark === 'E') fail(index);
624
+ else skipped += 1;
625
+ }
626
+ }
627
+ return;
628
+ }
629
+ if (runner === 'tap' || (runner === 'node:test' && /^\s*(?:not )?ok \d+ /.test(text))) {
630
+ const tap = /^\s*(not )?ok \d+\b(.*)$/.exec(text);
631
+ if (!tap) return;
632
+ // A subtest line is indented; only top-level results count.
633
+ if (/^\s/.test(text)) return;
634
+ if (/#\s*(?:SKIP|TODO)\b/i.test(tap[2])) skipped += repeat;
635
+ else if (tap[1]) fail(index, repeat);
636
+ else passed += repeat;
637
+ return;
638
+ }
639
+ if (runner === 'vitest') {
640
+ // File lines: " ✓ src/a.test.ts (12 tests) 34ms", " ❯ src/b.test.ts (5 tests | 2 failed) 20ms".
641
+ // The × lines under a failed file repeat its failures.
642
+ const file = /^\s*[✓❯×]\s+\S+\s+\((\d+) tests?(?: \| (\d+) failed)?(?: \| (\d+) skipped)?\)/.exec(text);
643
+ if (file) {
644
+ const total = Number(file[1]);
645
+ const failedHere = Number(file[2] || 0);
646
+ const skippedHere = Number(file[3] || 0);
647
+ passed += Math.max(0, total - failedHere - skippedHere);
648
+ skipped += skippedHere;
649
+ if (failedHere) fail(index, failedHere);
650
+ return;
651
+ }
652
+ if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
653
+ return;
654
+ }
655
+ if (runner === 'jest') {
656
+ // Without --verbose jest marks files only; each failed test gets a
657
+ // "● suite › test" block (but "● Console" is a log block).
658
+ if (/^\s*FAIL\s/.test(text)) {
659
+ if (firstFailureLine === null) firstFailureLine = index;
660
+ return;
661
+ }
662
+ if (/^\s*●\s(?!Console\b)/.test(text)) {
663
+ fail(index, repeat);
664
+ return;
665
+ }
666
+ if (PASS_MARK.test(text)) passed += repeat;
667
+ else if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
668
+ return;
669
+ }
670
+ if (runner === 'playwright' && /^\s*-\s+\d+\s/.test(text)) {
671
+ skipped += repeat;
672
+ return;
673
+ }
674
+ if (PASS_MARK.test(text)) passed += repeat;
675
+ else if (FAIL_MARK.test(text)) {
676
+ // node:test's "✖ failing tests:" heads the repeat of the failures.
677
+ if (/failing tests:\s*$/.test(text)) return;
678
+ fail(index, repeat);
679
+ }
680
+ });
681
+ return { passed, failed, skipped, firstFailureLine };
682
+ }
683
+
684
+ // Where the first failure is told in detail, for a run that has its final
685
+ // summary (the per-test marks may sit far above).
686
+ function firstFailureIndex(runner, texts) {
687
+ const patterns = {
688
+ playwright: [/^\s{2}\d+\) /, /^\s*[✘×]\s+\d+\s/],
689
+ vitest: [/^\s*FAIL\s/, /^\s*[×✗]\s/],
690
+ jest: [/^\s*●\s/, /^\s*[✕×]\s/],
691
+ pytest: [/^_{3,} .+ _{3,}$/, /\s(?:FAILED|ERROR)(?:\s+\[\s*\d+%\])?\s*$/, /^\S+\.py\s+[.sxX]*[FE]/],
692
+ 'node:test': [/^\s*✖\s(?!failing tests:)/, /^not ok \d+ /],
693
+ tap: [/^not ok \d+ /],
694
+ turbo: [/ERR!|ERROR|command finished with error/i],
695
+ }[runner] || [FAIL_MARK];
696
+ // In order of preference: the detailed report first, the mark second.
697
+ for (const pattern of patterns) {
698
+ const index = texts.findIndex((text) => pattern.test(text));
699
+ if (index !== -1) return index;
700
+ }
701
+ return null;
702
+ }
703
+
704
+ /**
705
+ * Pass/fail counts of a test run from its log lines (strings or the line
706
+ * model's `{ text, count }`; colours are ignored), or null when the log is
707
+ * not a known runner's. Returns `{ runner, passed, failed, skipped, flaky?,
708
+ * firstFailureLine, final }`: `final` when the counts come from the runner's
709
+ * closing summary, otherwise they are counted from the per-test marks while
710
+ * the run is still going. `firstFailureLine` is the index (into `lines`) of
711
+ * the first failure, or null. Runners: vitest, Playwright, node:test (spec
712
+ * and TAP), jest, pytest, turbo (and pnpm running it).
713
+ */
714
+ export function parseRunnerSummary(lines) {
715
+ const list = Array.isArray(lines) ? lines : String(lines ?? '').split('\n');
716
+ const texts = list.map(lineTextOf);
717
+ texts.counts = list.map(countOf);
718
+ for (let index = texts.length - 1; index >= 0; index -= 1) {
719
+ if (!texts[index].trim()) continue;
720
+ for (const candidate of FINAL_SUMMARIES) {
721
+ const found = candidate.match(texts, index);
722
+ if (!found) continue;
723
+ const failure = found.failed > 0 ? firstFailureIndex(found.runner, texts.slice(0, index)) : null;
724
+ return { ...found, firstFailureLine: failure, final: true };
725
+ }
726
+ }
727
+ let runner = null;
728
+ for (const text of texts) {
729
+ const header = RUNNER_HEADERS.find(([, pattern]) => pattern.test(text));
730
+ if (header) {
731
+ runner = header[0];
732
+ break;
733
+ }
734
+ }
735
+ if (!runner) return null;
736
+ return { runner, ...liveCounts(runner, texts), final: false };
737
+ }