@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.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Who receives a background task's output live: the pages whose console
|
|
4
|
+
// shows that task. The console joins with `task_output_watch { conversationId,
|
|
5
|
+
// taskId, offset }` (on open and after every reconnect) and leaves with
|
|
6
|
+
// `task_output_unwatch`; a socket is in at most one task room. While a room
|
|
7
|
+
// has members the relay stats the task's file every second and sends what
|
|
8
|
+
// was appended as `task_output_chunk { conversationId, taskId, offset,
|
|
9
|
+
// endOffset, text, size, mtime, ended }` (at most 64 KB per emit, cleaned like
|
|
10
|
+
// every other read). A page whose next expected offset is not `offset`
|
|
11
|
+
// fetches the tail again; a file that shrank is sent as an empty chunk with
|
|
12
|
+
// `reset: true`. Nobody watching costs nothing: the room's poller stops when
|
|
13
|
+
// it empties.
|
|
14
|
+
|
|
15
|
+
import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
|
|
16
|
+
import { inspectOutputFile, readRange, taskOutputSource } from './task-output-service.mjs';
|
|
17
|
+
|
|
18
|
+
const ROOM_PREFIX = 'task-output:';
|
|
19
|
+
// Chunks one tick may send for a fast writer (4 × 64 KB a second).
|
|
20
|
+
const MAX_CHUNKS_PER_TICK = 4;
|
|
21
|
+
// A watcher whose offset is this far behind the room is caught up by itself;
|
|
22
|
+
// further back, it fetches the tail.
|
|
23
|
+
const CATCH_UP_BYTES = 256 * 1024;
|
|
24
|
+
|
|
25
|
+
export function taskOutputRoom(conversationId, taskId) {
|
|
26
|
+
return `${ROOM_PREFIX}${String(conversationId || '')}:${String(taskId || '')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeAck(ack, payload) {
|
|
30
|
+
if (typeof ack !== 'function') return;
|
|
31
|
+
try { ack(payload); } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function idOf(value) {
|
|
35
|
+
const text = String(value ?? '').trim();
|
|
36
|
+
return text && text.length <= 200 && !/[\s\0]/.test(text) ? text : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `resolveTask(conversationId, taskId)` returns `{ task, ended, key }` for a
|
|
41
|
+
* task in the conversation's live or ended set, or null: the same access
|
|
42
|
+
* check as the output route. Sockets reaching `registerSocket` are already
|
|
43
|
+
* authenticated.
|
|
44
|
+
*/
|
|
45
|
+
export function createTaskOutputRooms({
|
|
46
|
+
io,
|
|
47
|
+
resolveTask,
|
|
48
|
+
intervalMs = 1000,
|
|
49
|
+
chunkBytes = TASK_OUTPUT_LIMITS.chunkBytes,
|
|
50
|
+
roomSize = (room) => io?.sockets?.adapter?.rooms?.get?.(room)?.size || 0,
|
|
51
|
+
readChunk = readRange,
|
|
52
|
+
inspect = inspectOutputFile,
|
|
53
|
+
setIntervalImpl = setInterval,
|
|
54
|
+
clearIntervalImpl = clearInterval,
|
|
55
|
+
logger = console,
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (typeof resolveTask !== 'function') throw new Error('createTaskOutputRooms requires resolveTask');
|
|
58
|
+
// room → { conversationId, taskId, path, offset, timer, busy }
|
|
59
|
+
const watched = new Map();
|
|
60
|
+
|
|
61
|
+
function stopRoom(room) {
|
|
62
|
+
const entry = watched.get(room);
|
|
63
|
+
if (!entry) return;
|
|
64
|
+
if (entry.timer) clearIntervalImpl(entry.timer);
|
|
65
|
+
watched.delete(room);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function leave(socket) {
|
|
69
|
+
const current = socket.data?.taskOutputRoom;
|
|
70
|
+
if (current) socket.leave(current);
|
|
71
|
+
if (socket.data) socket.data.taskOutputRoom = null;
|
|
72
|
+
if (current && roomSize(current) === 0) stopRoom(current);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lookup(conversationId, taskId) {
|
|
76
|
+
try {
|
|
77
|
+
return resolveTask(conversationId, taskId);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Where a room may start in a file of `size` bytes: after the last line
|
|
84
|
+
// break of its last 64 KB (never inside a character, an escape or a line
|
|
85
|
+
// whose credentials would be scrubbed in two halves), 0 when there is none
|
|
86
|
+
// in a shorter file.
|
|
87
|
+
async function safeOffset(path, size) {
|
|
88
|
+
if (!(size > 0)) return 0;
|
|
89
|
+
const start = Math.max(0, size - chunkBytes);
|
|
90
|
+
try {
|
|
91
|
+
const probe = await readChunk(path, { start, maxBytes: size - start, holdOpenLine: true, longLineBytes: chunkBytes });
|
|
92
|
+
return probe?.ok && Number.isInteger(probe.endOffset) ? probe.endOffset : size;
|
|
93
|
+
} catch {
|
|
94
|
+
return size;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `flush`: send the open last line too (the writer is idle, the task ended,
|
|
99
|
+
// or the bytes were already sent to the room).
|
|
100
|
+
async function sendRange(target, entry, start, end, ended, { flush = true } = {}) {
|
|
101
|
+
let offset = start;
|
|
102
|
+
for (let sent = 0; sent < MAX_CHUNKS_PER_TICK && offset < end; sent += 1) {
|
|
103
|
+
const chunk = await readChunk(entry.path, {
|
|
104
|
+
start: offset,
|
|
105
|
+
maxBytes: Math.min(chunkBytes, end - offset),
|
|
106
|
+
holdOpenLine: !flush,
|
|
107
|
+
longLineBytes: chunkBytes,
|
|
108
|
+
});
|
|
109
|
+
if (!chunk.ok || chunk.endOffset <= offset) break;
|
|
110
|
+
target.emit('task_output_chunk', {
|
|
111
|
+
conversationId: entry.conversationId,
|
|
112
|
+
taskId: entry.taskId,
|
|
113
|
+
offset: chunk.offset,
|
|
114
|
+
endOffset: chunk.endOffset,
|
|
115
|
+
text: chunk.text,
|
|
116
|
+
size: chunk.size,
|
|
117
|
+
mtime: chunk.mtime,
|
|
118
|
+
ended,
|
|
119
|
+
});
|
|
120
|
+
offset = chunk.endOffset;
|
|
121
|
+
}
|
|
122
|
+
return offset;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function poll(room) {
|
|
126
|
+
const entry = watched.get(room);
|
|
127
|
+
if (!entry || entry.busy) return;
|
|
128
|
+
if (roomSize(room) === 0) {
|
|
129
|
+
stopRoom(room);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
entry.busy = true;
|
|
133
|
+
try {
|
|
134
|
+
const found = lookup(entry.conversationId, entry.taskId);
|
|
135
|
+
// The task left both sets (an hour after its end): nothing more comes.
|
|
136
|
+
if (found === null) {
|
|
137
|
+
stopRoom(room);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const ended = !!found?.ended;
|
|
141
|
+
const info = await inspect(entry.path);
|
|
142
|
+
// Not there (yet, or any more): keep waiting.
|
|
143
|
+
if (!info.ok) return;
|
|
144
|
+
const target = io.to(room);
|
|
145
|
+
// Unchanged since the last tick: the writer is idle, an open line goes out.
|
|
146
|
+
const idle = info.size === entry.lastSize;
|
|
147
|
+
entry.lastSize = info.size;
|
|
148
|
+
if (info.size < entry.offset) {
|
|
149
|
+
entry.offset = await safeOffset(entry.path, info.size);
|
|
150
|
+
target.emit('task_output_chunk', {
|
|
151
|
+
conversationId: entry.conversationId,
|
|
152
|
+
taskId: entry.taskId,
|
|
153
|
+
offset: entry.offset,
|
|
154
|
+
endOffset: entry.offset,
|
|
155
|
+
text: '',
|
|
156
|
+
size: info.size,
|
|
157
|
+
mtime: info.mtimeMs,
|
|
158
|
+
ended,
|
|
159
|
+
reset: true,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (info.size > entry.offset) {
|
|
164
|
+
entry.offset = await sendRange(target, entry, entry.offset, info.size, ended, { flush: idle || ended });
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logger?.warn?.(`[task-output] live read failed for task ${String(entry.taskId).slice(0, 12)}: ${error?.message || error}`);
|
|
168
|
+
} finally {
|
|
169
|
+
const still = watched.get(room);
|
|
170
|
+
if (still) still.busy = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function watch(socket, payload) {
|
|
175
|
+
const conversationId = idOf(payload?.conversationId);
|
|
176
|
+
const taskId = idOf(payload?.taskId);
|
|
177
|
+
if (!conversationId || !taskId) return { ok: false, code: 'missing-id' };
|
|
178
|
+
const found = lookup(conversationId, taskId);
|
|
179
|
+
if (found === undefined) return { ok: false, code: 'unavailable' };
|
|
180
|
+
if (!found) return { ok: false, code: 'not-found' };
|
|
181
|
+
const { kind, path } = taskOutputSource(found.task);
|
|
182
|
+
// A snapshot is fetched again by the console, `none` has nothing.
|
|
183
|
+
if (!path) return { ok: false, code: kind === 'snapshot' ? 'snapshot' : 'no-output' };
|
|
184
|
+
let info = await inspect(path);
|
|
185
|
+
// A file not there yet (a spool is named before its first byte) is
|
|
186
|
+
// waited for: the room starts at offset 0.
|
|
187
|
+
if (!info.ok && info.code === 'missing') info = { ok: true, size: 0, mtimeMs: null };
|
|
188
|
+
if (!info.ok) return { ok: false, code: info.code || 'unreadable' };
|
|
189
|
+
|
|
190
|
+
const room = taskOutputRoom(conversationId, taskId);
|
|
191
|
+
if (socket.data?.taskOutputRoom !== room) {
|
|
192
|
+
leave(socket);
|
|
193
|
+
socket.join(room);
|
|
194
|
+
if (socket.data) socket.data.taskOutputRoom = room;
|
|
195
|
+
} else if (!socket.rooms?.has?.(room)) {
|
|
196
|
+
// A recovered socket keeps its data; its rooms may or may not be back.
|
|
197
|
+
socket.join(room);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const requested = Number(payload?.offset);
|
|
201
|
+
const wanted = Number.isInteger(requested) && requested >= 0 && requested <= info.size ? requested : null;
|
|
202
|
+
// Where the page's tail ended, so nothing written between its fetch and
|
|
203
|
+
// this watch is lost; a page far behind (or one that names no offset)
|
|
204
|
+
// starts at a safe place near the end and fetches the tail up to it.
|
|
205
|
+
const startOffset = watched.has(room) ? 0
|
|
206
|
+
: (wanted !== null && info.size - wanted <= CATCH_UP_BYTES ? wanted : await safeOffset(path, info.size));
|
|
207
|
+
let entry = watched.get(room);
|
|
208
|
+
if (!entry) {
|
|
209
|
+
entry = {
|
|
210
|
+
conversationId,
|
|
211
|
+
taskId,
|
|
212
|
+
path,
|
|
213
|
+
offset: startOffset,
|
|
214
|
+
lastSize: info.size,
|
|
215
|
+
timer: null,
|
|
216
|
+
busy: false,
|
|
217
|
+
};
|
|
218
|
+
watched.set(room, entry);
|
|
219
|
+
entry.timer = setIntervalImpl(() => { poll(room).catch(() => {}); }, intervalMs);
|
|
220
|
+
entry.timer?.unref?.();
|
|
221
|
+
} else if (wanted !== null && wanted < entry.offset && entry.offset - wanted <= CATCH_UP_BYTES) {
|
|
222
|
+
// A second page joins behind the room: it gets the gap on its own.
|
|
223
|
+
await sendRange(socket, entry, wanted, entry.offset, !!found.ended);
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, taskId, offset: entry.offset, size: info.size };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function registerSocket(socket) {
|
|
229
|
+
if (!socket || typeof socket.on !== 'function') return;
|
|
230
|
+
socket.on('task_output_watch', (payload, ack) => {
|
|
231
|
+
watch(socket, payload)
|
|
232
|
+
.then((result) => {
|
|
233
|
+
// A refused watch leaves no stale room behind: the page shows another task now.
|
|
234
|
+
if (!result.ok) leave(socket);
|
|
235
|
+
safeAck(ack, result);
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
leave(socket);
|
|
239
|
+
safeAck(ack, { ok: false, code: 'unavailable' });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
socket.on('task_output_unwatch', (_payload, ack) => {
|
|
243
|
+
leave(socket);
|
|
244
|
+
safeAck(ack, { ok: true });
|
|
245
|
+
});
|
|
246
|
+
// socket.io takes a closed socket out of its rooms; the poller stops at
|
|
247
|
+
// its next tick when that emptied the room.
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
registerSocket,
|
|
252
|
+
/** Rooms with a poller, for tests and diagnostics. */
|
|
253
|
+
watchedRooms: () => [...watched.keys()],
|
|
254
|
+
poll,
|
|
255
|
+
stop() {
|
|
256
|
+
for (const room of [...watched.keys()]) stopRoom(room);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|