@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.
- package/CHANGELOG.md +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- 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 +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -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 +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- 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/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- 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/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -11
- 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 +20 -1
- package/server/public/index.html +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The spool a session worker writes a background task's output into when the
|
|
2
|
+
// provider keeps none in a file of its own (Copilot's attached shells, Grok's
|
|
3
|
+
// terminals): `<os.tmpdir()>/oar-task-output/<sessionId>/<taskId>.log`,
|
|
4
|
+
// append-only. The worker names the file as the task row's `outputPath`
|
|
5
|
+
// (`outputKind: 'spool'`) and the relay reads it like any other output file,
|
|
6
|
+
// so the console works the same for every provider and outlives a worker
|
|
7
|
+
// restart.
|
|
8
|
+
//
|
|
9
|
+
// Bounded: a file stops growing at 50 MB with one "output capped" line, and
|
|
10
|
+
// `sweep()` (at worker start and hourly) deletes files that ended more than an
|
|
11
|
+
// hour ago. Nothing is deleted at exit: the relay keeps showing an ended
|
|
12
|
+
// task's output for about an hour.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const TASK_OUTPUT_SPOOL_DIR = 'oar-task-output';
|
|
19
|
+
export const TASK_OUTPUT_SPOOL_MAX_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
export const TASK_OUTPUT_SPOOL_MAX_AGE_MS = 60 * 60 * 1000;
|
|
21
|
+
// A dead worker's session folder is left for its own next start to sweep;
|
|
22
|
+
// another session's worker removes it only once it is a day old.
|
|
23
|
+
export const TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
const CAPPED_LINE = '\n[output capped at 50 MB: later output is not kept]\n';
|
|
26
|
+
|
|
27
|
+
/** A file-name-safe form of an id: [A-Za-z0-9._-], at most 128 characters, never `.` or `..`. */
|
|
28
|
+
export function spoolSafeName(value) {
|
|
29
|
+
const text = String(value ?? '').trim().replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
|
|
30
|
+
return /^\.+$/.test(text) ? text.replace(/\./g, '_') : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `createTaskOutputSpool({ sessionId })` →
|
|
35
|
+
* `{ root, pathFor(taskId), append(taskId, text), end(taskId), sweep(maxAgeMs) }`.
|
|
36
|
+
*
|
|
37
|
+
* - `pathFor(taskId)`: the absolute file path for the task's row.
|
|
38
|
+
* - `append(taskId, text)`: `{ ok, bytes, capped }`; never throws. The cap
|
|
39
|
+
* writes one marker line and drops what comes after it.
|
|
40
|
+
* - `end(taskId)`: the task finished; its file stays for the relay until a
|
|
41
|
+
* sweep finds it older than the age limit.
|
|
42
|
+
* - `sweep(maxAgeMs = 1 h)`: deletes this session's files not written to for
|
|
43
|
+
* that long (a task still appending is never swept), and other sessions'
|
|
44
|
+
* folders a day old. Returns the number of files removed.
|
|
45
|
+
*
|
|
46
|
+
* `baseDir` (default the OS temp dir) and `now` are for tests.
|
|
47
|
+
*/
|
|
48
|
+
export function createTaskOutputSpool({
|
|
49
|
+
sessionId,
|
|
50
|
+
now = () => Date.now(),
|
|
51
|
+
baseDir = os.tmpdir(),
|
|
52
|
+
maxBytes = TASK_OUTPUT_SPOOL_MAX_BYTES,
|
|
53
|
+
fsImpl = fs,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const sessionName = spoolSafeName(sessionId);
|
|
56
|
+
if (!sessionName) throw new Error('createTaskOutputSpool requires sessionId');
|
|
57
|
+
const top = path.join(path.resolve(baseDir), TASK_OUTPUT_SPOOL_DIR);
|
|
58
|
+
const root = path.join(top, sessionName);
|
|
59
|
+
// taskId → { bytes, capped }; a task listed here is live (still appending).
|
|
60
|
+
const live = new Map();
|
|
61
|
+
let rootReady = false;
|
|
62
|
+
|
|
63
|
+
// The shared folder is ours to write and sweep only when it is a real
|
|
64
|
+
// folder (not a symlink someone else put into a shared temp dir) owned by
|
|
65
|
+
// this user where the platform says who owns it.
|
|
66
|
+
function topIsOurs() {
|
|
67
|
+
let stats;
|
|
68
|
+
try { stats = fsImpl.lstatSync(top); } catch { return false; }
|
|
69
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
|
|
70
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
71
|
+
return uid === null || typeof stats.uid !== 'number' || stats.uid === uid;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureRoot() {
|
|
75
|
+
if (rootReady) return;
|
|
76
|
+
// Owner-only: the temp dir is shared on a multi-user host.
|
|
77
|
+
try { fsImpl.mkdirSync(top, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
78
|
+
if (!topIsOurs()) throw new Error(`${top} is not a folder of this user`);
|
|
79
|
+
fsImpl.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
80
|
+
rootReady = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pathFor(taskId) {
|
|
84
|
+
const name = spoolSafeName(taskId);
|
|
85
|
+
if (!name) throw new Error('task id is empty');
|
|
86
|
+
return path.join(root, `${name}.log`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stateOf(taskId, file) {
|
|
90
|
+
let state = live.get(taskId);
|
|
91
|
+
if (!state) {
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
try { bytes = fsImpl.statSync(file).size; } catch {}
|
|
94
|
+
state = { bytes, capped: bytes >= maxBytes };
|
|
95
|
+
live.set(taskId, state);
|
|
96
|
+
}
|
|
97
|
+
return state;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function append(taskId, text) {
|
|
101
|
+
const id = String(taskId ?? '').trim();
|
|
102
|
+
const value = typeof text === 'string' ? text : (Buffer.isBuffer(text) ? text.toString('utf8') : String(text ?? ''));
|
|
103
|
+
if (!id) return { ok: false, bytes: 0, capped: false };
|
|
104
|
+
try {
|
|
105
|
+
ensureRoot();
|
|
106
|
+
const file = pathFor(id);
|
|
107
|
+
const state = stateOf(id, file);
|
|
108
|
+
if (state.capped || !value) return { ok: true, bytes: state.bytes, capped: state.capped };
|
|
109
|
+
let data = Buffer.from(value, 'utf8');
|
|
110
|
+
const room = maxBytes - state.bytes;
|
|
111
|
+
if (data.length > room) {
|
|
112
|
+
// Cut on a character boundary, then say so once.
|
|
113
|
+
let end = Math.max(0, room);
|
|
114
|
+
while (end > 0 && (data[end] & 0xc0) === 0x80) end -= 1;
|
|
115
|
+
data = Buffer.concat([data.subarray(0, end), Buffer.from(CAPPED_LINE, 'utf8')]);
|
|
116
|
+
state.capped = true;
|
|
117
|
+
}
|
|
118
|
+
fsImpl.appendFileSync(file, data, { mode: 0o600 });
|
|
119
|
+
state.bytes += data.length;
|
|
120
|
+
return { ok: true, bytes: state.bytes, capped: state.capped };
|
|
121
|
+
} catch {
|
|
122
|
+
return { ok: false, bytes: live.get(id)?.bytes || 0, capped: !!live.get(id)?.capped };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function end(taskId) {
|
|
127
|
+
live.delete(String(taskId ?? '').trim());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function liveNames() {
|
|
131
|
+
return new Set([...live.keys()].map((id) => `${spoolSafeName(id)}.log`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sweepDir(dir, maxAge, skip = new Set()) {
|
|
135
|
+
let removed = 0;
|
|
136
|
+
let left = 0;
|
|
137
|
+
let entries = [];
|
|
138
|
+
try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return { removed, left: 0, missing: true }; }
|
|
139
|
+
const cutoff = now() - maxAge;
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isFile() || skip.has(entry.name)) {
|
|
142
|
+
left += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const file = path.join(dir, entry.name);
|
|
146
|
+
try {
|
|
147
|
+
if (fsImpl.statSync(file).mtimeMs < cutoff) {
|
|
148
|
+
fsImpl.unlinkSync(file);
|
|
149
|
+
removed += 1;
|
|
150
|
+
} else {
|
|
151
|
+
left += 1;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
left += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { removed, left, missing: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sweep(maxAgeMs = TASK_OUTPUT_SPOOL_MAX_AGE_MS) {
|
|
161
|
+
const maxAge = Number.isFinite(Number(maxAgeMs)) && Number(maxAgeMs) >= 0 ? Number(maxAgeMs) : TASK_OUTPUT_SPOOL_MAX_AGE_MS;
|
|
162
|
+
// Never through a symlinked or foreign shared folder: its "session
|
|
163
|
+
// folders" could be anything.
|
|
164
|
+
if (!topIsOurs()) return 0;
|
|
165
|
+
let removed = sweepDir(root, maxAge, liveNames()).removed;
|
|
166
|
+
let folders = [];
|
|
167
|
+
try { folders = fsImpl.readdirSync(top, { withFileTypes: true }); } catch {}
|
|
168
|
+
for (const folder of folders) {
|
|
169
|
+
if (!folder.isDirectory() || folder.name === sessionName) continue;
|
|
170
|
+
const dir = path.join(top, folder.name);
|
|
171
|
+
const result = sweepDir(dir, Math.max(maxAge, TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS));
|
|
172
|
+
removed += result.removed;
|
|
173
|
+
if (!result.missing && result.left === 0) {
|
|
174
|
+
try { fsImpl.rmdirSync(dir); } catch {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return removed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { root, pathFor, append, end, sweep };
|
|
181
|
+
}
|