outsrc 0.2.0 → 0.2.1
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/README.md +103 -1
- package/dist/cli.js +128 -7
- package/dist/mailbox.d.ts +106 -1
- package/dist/mailbox.js +98 -33
- package/dist/model-cache.d.ts +29 -0
- package/dist/model-cache.js +62 -0
- package/dist/models.d.ts +10 -0
- package/dist/models.js +108 -0
- package/dist/server.js +7 -2
- package/dist/state.d.ts +24 -0
- package/dist/state.js +10 -0
- package/dist/streams.d.ts +49 -0
- package/dist/streams.js +388 -0
- package/dist/types.d.ts +12 -2
- package/dist/update-check.d.ts +18 -0
- package/dist/update-check.js +84 -0
- package/dist/updates.d.ts +78 -0
- package/dist/updates.js +136 -0
- package/dist/usage.d.ts +105 -0
- package/dist/usage.js +106 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +45 -0
- package/dist/wrapper.js +53 -9
- package/package.json +4 -4
package/dist/streams.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pidAlive } from "./job.js";
|
|
6
|
+
import { threadsDir } from "./paths.js";
|
|
7
|
+
import { ProcessSchema, readJson, readResult, RunSchema, ThreadSchema } from "./state.js";
|
|
8
|
+
import { MINTED_THREAD_ID, STORAGE_ID } from "./types.js";
|
|
9
|
+
export const INITIAL_TAIL_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_CHUNK_BYTES = 256 * 1024;
|
|
11
|
+
function logSize(file) {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(file).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function readPane(home, id) {
|
|
20
|
+
const dir = join(threadsDir(home), id);
|
|
21
|
+
const thread = ThreadSchema.safeParse(readJson(join(dir, "thread.json")));
|
|
22
|
+
if (!thread.success || !STORAGE_ID.test(thread.data.latestRunId))
|
|
23
|
+
return null;
|
|
24
|
+
const runDir = join(dir, "runs", thread.data.latestRunId);
|
|
25
|
+
const run = RunSchema.safeParse(readJson(join(runDir, "run.json")));
|
|
26
|
+
if (!run.success)
|
|
27
|
+
return null;
|
|
28
|
+
const result = readResult(runDir);
|
|
29
|
+
let status;
|
|
30
|
+
if (result)
|
|
31
|
+
status = result.kind === "completed" ? "succeeded" : result.kind;
|
|
32
|
+
else if (existsSync(join(runDir, "cancelled")))
|
|
33
|
+
status = "cancelled";
|
|
34
|
+
else {
|
|
35
|
+
const file = join(runDir, "process.json");
|
|
36
|
+
const recorded = existsSync(file) ? ProcessSchema.safeParse(readJson(file)) : null;
|
|
37
|
+
status = recorded && (!recorded.success || !pidAlive(recorded.data.pid)) ? "stopped" : "working";
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
thread_id: thread.data.id, repo: thread.data.repo, target: thread.data.target, branch: thread.data.branch,
|
|
41
|
+
status, live: status === "working", created_at: thread.data.createdAt, run_id: run.data.id,
|
|
42
|
+
run_started_at: run.data.createdAt, finished_at: result?.finishedAt ?? null, log_size: logSize(join(runDir, "run.log")),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns a scanner over `<home>/threads`. Working threads are always listed; finished ones only if they finished
|
|
47
|
+
* within `recentMs`, or if they are the pinned `thread`. A finished thread whose thread.json has not changed is not
|
|
48
|
+
* reread, so each tick costs one stat per old thread.
|
|
49
|
+
*/
|
|
50
|
+
export function createScanner(input) {
|
|
51
|
+
const cache = new Map();
|
|
52
|
+
return (now = Date.now()) => {
|
|
53
|
+
const root = threadsDir(input.home);
|
|
54
|
+
let names;
|
|
55
|
+
try {
|
|
56
|
+
names = readdirSync(root);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
names = [];
|
|
60
|
+
}
|
|
61
|
+
if (input.thread)
|
|
62
|
+
names = names.filter((name) => name === input.thread);
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const panes = [];
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (!MINTED_THREAD_ID.test(name))
|
|
67
|
+
continue;
|
|
68
|
+
seen.add(name);
|
|
69
|
+
let mtimeMs;
|
|
70
|
+
try {
|
|
71
|
+
mtimeMs = statSync(join(root, name, "thread.json")).mtimeMs;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const cached = cache.get(name);
|
|
77
|
+
let pane;
|
|
78
|
+
if (cached && cached.mtimeMs === mtimeMs && !cached.pane.live && cached.pane.status !== "stopped" && cached.pane.status !== "needs_input")
|
|
79
|
+
pane = cached.pane;
|
|
80
|
+
else {
|
|
81
|
+
try {
|
|
82
|
+
pane = readPane(input.home, name);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
pane = null;
|
|
86
|
+
}
|
|
87
|
+
if (!pane)
|
|
88
|
+
continue;
|
|
89
|
+
cache.set(name, { mtimeMs, pane });
|
|
90
|
+
}
|
|
91
|
+
const recent = pane.finished_at !== null && now - Date.parse(pane.finished_at) < input.recentMs;
|
|
92
|
+
if (pane.live || pane.status === "needs_input" || pane.status === "stopped" || recent || input.thread)
|
|
93
|
+
panes.push(pane);
|
|
94
|
+
}
|
|
95
|
+
for (const name of cache.keys())
|
|
96
|
+
if (!seen.has(name))
|
|
97
|
+
cache.delete(name);
|
|
98
|
+
return panes.sort((a, b) => Number(b.live) - Number(a.live) || b.created_at.localeCompare(a.created_at) || a.thread_id.localeCompare(b.thread_id));
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Reads a run log from `offset`. `reset` is true when the file shrank or the caller asks for the first read. */
|
|
102
|
+
export function readLogFrom(home, pane, offset) {
|
|
103
|
+
const file = join(threadsDir(home), pane.thread_id, "runs", pane.run_id, "run.log");
|
|
104
|
+
const size = logSize(file);
|
|
105
|
+
const reset = offset === null || size < offset;
|
|
106
|
+
const start = reset ? Math.max(0, size - INITIAL_TAIL_BYTES) : offset;
|
|
107
|
+
const length = Math.min(size - start, MAX_CHUNK_BYTES);
|
|
108
|
+
if (length <= 0)
|
|
109
|
+
return { text: "", next: start, reset };
|
|
110
|
+
const buffer = Buffer.alloc(length);
|
|
111
|
+
const fd = openSync(file, "r");
|
|
112
|
+
try {
|
|
113
|
+
readSync(fd, buffer, 0, length, start);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
// Hold back a split UTF-8 sequence at the end so it is sent whole with the next chunk.
|
|
119
|
+
let end = length;
|
|
120
|
+
for (let back = 1; back <= Math.min(3, length); back++) {
|
|
121
|
+
const byte = buffer[length - back] ?? 0;
|
|
122
|
+
if ((byte & 0xc0) === 0x80)
|
|
123
|
+
continue;
|
|
124
|
+
const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
|
|
125
|
+
if (width > back)
|
|
126
|
+
end = length - back;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
return { text: buffer.subarray(0, end).toString("utf8"), next: start + end, reset };
|
|
130
|
+
}
|
|
131
|
+
export async function startStreamServer(input) {
|
|
132
|
+
const host = input.host ?? "127.0.0.1";
|
|
133
|
+
const token = input.token ?? randomBytes(16).toString("hex");
|
|
134
|
+
const pollMs = Math.min(input.pollMs ?? 1000, 1000);
|
|
135
|
+
const scan = createScanner({ home: input.home, recentMs: input.recentMs ?? 15 * 60_000, ...(input.thread ? { thread: input.thread } : {}) });
|
|
136
|
+
const clients = new Set();
|
|
137
|
+
let timer = null;
|
|
138
|
+
let heartbeat = 0;
|
|
139
|
+
function send(client, event, data) {
|
|
140
|
+
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
141
|
+
}
|
|
142
|
+
function tick() {
|
|
143
|
+
const panes = scan();
|
|
144
|
+
const listed = panes.map(({ log_size: _size, ...pane }) => pane);
|
|
145
|
+
const listing = JSON.stringify(listed);
|
|
146
|
+
for (const client of clients) {
|
|
147
|
+
if (listing !== client.lastList) {
|
|
148
|
+
send(client, "threads", { home: input.home, pinned: input.thread ?? null, now: new Date().toISOString(), threads: listed });
|
|
149
|
+
client.lastList = listing;
|
|
150
|
+
}
|
|
151
|
+
for (const pane of panes) {
|
|
152
|
+
const known = client.offsets.get(pane.thread_id);
|
|
153
|
+
const sameRun = known?.runId === pane.run_id;
|
|
154
|
+
if (sameRun && known.offset === pane.log_size)
|
|
155
|
+
continue;
|
|
156
|
+
const chunk = readLogFrom(input.home, pane, sameRun ? known.offset : null);
|
|
157
|
+
client.offsets.set(pane.thread_id, { runId: pane.run_id, offset: chunk.next });
|
|
158
|
+
if (chunk.text || chunk.reset)
|
|
159
|
+
send(client, "log", { thread_id: pane.thread_id, run_id: pane.run_id, text: chunk.text, reset: chunk.reset });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// A comment line every 15s keeps proxies and browsers from dropping an idle stream.
|
|
163
|
+
if (++heartbeat * pollMs >= 15_000) {
|
|
164
|
+
heartbeat = 0;
|
|
165
|
+
for (const client of clients)
|
|
166
|
+
client.res.write(": keepalive\n\n");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Polling runs only while a browser is connected; with no viewer the process just holds a listening socket.
|
|
170
|
+
function schedule() {
|
|
171
|
+
if (clients.size > 0 && !timer)
|
|
172
|
+
timer = setInterval(tick, pollMs);
|
|
173
|
+
if (clients.size === 0 && timer) {
|
|
174
|
+
clearInterval(timer);
|
|
175
|
+
timer = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function authorized(req, url) {
|
|
179
|
+
// Rejecting other Host headers stops a web page from reading the stream through DNS rebinding.
|
|
180
|
+
const hostHeader = (req.headers.host ?? "").replace(/:\d+$/, "");
|
|
181
|
+
if (hostHeader !== "127.0.0.1" && hostHeader !== "localhost" && hostHeader !== "[::1]")
|
|
182
|
+
return false;
|
|
183
|
+
return url.searchParams.get("token") === token;
|
|
184
|
+
}
|
|
185
|
+
const server = createServer((req, res) => {
|
|
186
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
187
|
+
if (req.method !== "GET" || !authorized(req, url)) {
|
|
188
|
+
res.writeHead(403, { "content-type": "text/plain" }).end("forbidden\n");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (url.pathname === "/") {
|
|
192
|
+
res.writeHead(200, {
|
|
193
|
+
"content-type": "text/html; charset=utf-8",
|
|
194
|
+
"cache-control": "no-store",
|
|
195
|
+
"content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'",
|
|
196
|
+
"referrer-policy": "no-referrer",
|
|
197
|
+
}).end(PAGE);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (url.pathname === "/events") {
|
|
201
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
202
|
+
res.write("retry: 1000\n\n");
|
|
203
|
+
const client = { res, offsets: new Map(), lastList: "" };
|
|
204
|
+
clients.add(client);
|
|
205
|
+
req.on("close", () => { clients.delete(client); schedule(); });
|
|
206
|
+
schedule();
|
|
207
|
+
tick();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("not found\n");
|
|
211
|
+
});
|
|
212
|
+
await new Promise((resolve, reject) => {
|
|
213
|
+
server.once("error", reject);
|
|
214
|
+
server.listen(input.port ?? 0, host, () => { server.off("error", reject); resolve(); });
|
|
215
|
+
});
|
|
216
|
+
const address = server.address();
|
|
217
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
218
|
+
return {
|
|
219
|
+
url: `http://${host}:${port}/?token=${token}`,
|
|
220
|
+
port,
|
|
221
|
+
token,
|
|
222
|
+
clients: () => clients.size,
|
|
223
|
+
close: () => new Promise((resolve) => {
|
|
224
|
+
if (timer)
|
|
225
|
+
clearInterval(timer);
|
|
226
|
+
timer = null;
|
|
227
|
+
for (const client of clients)
|
|
228
|
+
client.res.end();
|
|
229
|
+
clients.clear();
|
|
230
|
+
server.close(() => resolve());
|
|
231
|
+
server.closeAllConnections();
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const PAGE = `<!doctype html>
|
|
236
|
+
<html lang="en">
|
|
237
|
+
<head>
|
|
238
|
+
<meta charset="utf-8">
|
|
239
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
240
|
+
<title>outsrc streams</title>
|
|
241
|
+
<style>
|
|
242
|
+
:root { color-scheme: dark; --bg: #0d1014; --pane: #151a21; --line: #262d38; --text: #d7dde5; --dim: #7c8796; --live: #3fb950; --wait: #d29922; --bad: #f85149; }
|
|
243
|
+
* { box-sizing: border-box; }
|
|
244
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
245
|
+
header.top { display: flex; gap: 16px; align-items: baseline; padding: 10px 14px; border-bottom: 1px solid var(--line); }
|
|
246
|
+
header.top h1 { font-size: 14px; margin: 0; }
|
|
247
|
+
header.top .meta { color: var(--dim); }
|
|
248
|
+
#conn.down { color: var(--bad); }
|
|
249
|
+
#grid { display: grid; gap: 10px; padding: 10px; grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); }
|
|
250
|
+
.pane { display: flex; flex-direction: column; height: 46vh; min-height: 240px; background: var(--pane); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
251
|
+
.pane .head { display: flex; gap: 10px; align-items: center; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; overflow: hidden; }
|
|
252
|
+
.pane .id { font-weight: 600; }
|
|
253
|
+
.pane .dim { color: var(--dim); overflow: hidden; text-overflow: ellipsis; }
|
|
254
|
+
.pane .status { padding: 0 6px; border-radius: 4px; border: 1px solid currentColor; font-size: 11px; }
|
|
255
|
+
.status.working { color: var(--live); }
|
|
256
|
+
.status.needs_input, .status.stopped { color: var(--wait); }
|
|
257
|
+
.status.failed, .status.cancelled { color: var(--bad); }
|
|
258
|
+
.status.succeeded { color: var(--dim); }
|
|
259
|
+
.pane .spacer { flex: 1; }
|
|
260
|
+
.pane .paused { display: none; cursor: pointer; color: var(--wait); }
|
|
261
|
+
.pane.is-paused .paused { display: inline; }
|
|
262
|
+
.pane pre { flex: 1; margin: 0; padding: 8px 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
|
|
263
|
+
#strip { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 10px 10px; }
|
|
264
|
+
#strip:empty { display: none; }
|
|
265
|
+
#strip .done { opacity: .55; background: var(--pane); border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
|
266
|
+
#strip .done:hover { opacity: .9; }
|
|
267
|
+
.pane.muted { opacity: .6; }
|
|
268
|
+
#empty { padding: 40px 14px; color: var(--dim); }
|
|
269
|
+
</style>
|
|
270
|
+
</head>
|
|
271
|
+
<body>
|
|
272
|
+
<header class="top"><h1>outsrc streams</h1><span class="meta" id="home"></span><span class="meta" id="conn">connecting</span></header>
|
|
273
|
+
<div id="empty" hidden>No working threads. Watching for new ones.</div>
|
|
274
|
+
<div id="grid"></div>
|
|
275
|
+
<div id="strip"></div>
|
|
276
|
+
<script>
|
|
277
|
+
"use strict";
|
|
278
|
+
const MAX_CHARS = 200000;
|
|
279
|
+
const token = new URLSearchParams(location.search).get("token") || "";
|
|
280
|
+
const grid = document.getElementById("grid");
|
|
281
|
+
const strip = document.getElementById("strip");
|
|
282
|
+
const empty = document.getElementById("empty");
|
|
283
|
+
const conn = document.getElementById("conn");
|
|
284
|
+
const panes = new Map();
|
|
285
|
+
const expanded = new Set();
|
|
286
|
+
let threads = [];
|
|
287
|
+
let skew = 0;
|
|
288
|
+
|
|
289
|
+
function age(iso) {
|
|
290
|
+
const s = Math.max(0, Math.floor((Date.now() + skew - Date.parse(iso)) / 1000));
|
|
291
|
+
if (s < 60) return s + "s";
|
|
292
|
+
if (s < 3600) return Math.floor(s / 60) + "m" + String(s % 60).padStart(2, "0") + "s";
|
|
293
|
+
return Math.floor(s / 3600) + "h" + String(Math.floor(s / 60) % 60).padStart(2, "0") + "m";
|
|
294
|
+
}
|
|
295
|
+
function el(tag, cls, text) {
|
|
296
|
+
const node = document.createElement(tag);
|
|
297
|
+
if (cls) node.className = cls;
|
|
298
|
+
if (text !== undefined) node.textContent = text;
|
|
299
|
+
return node;
|
|
300
|
+
}
|
|
301
|
+
function makePane(t) {
|
|
302
|
+
const root = el("section", "pane");
|
|
303
|
+
const head = el("div", "head");
|
|
304
|
+
const id = el("span", "id", t.thread_id.slice(0, 8));
|
|
305
|
+
id.title = t.thread_id;
|
|
306
|
+
const status = el("span", "status");
|
|
307
|
+
const target = el("span", "", "");
|
|
308
|
+
const branch = el("span", "dim", "");
|
|
309
|
+
const spacer = el("span", "spacer");
|
|
310
|
+
const paused = el("span", "paused", "paused, click to follow");
|
|
311
|
+
const ageEl = el("span", "dim", "");
|
|
312
|
+
head.append(id, target, status, branch, spacer, paused, ageEl);
|
|
313
|
+
const body = el("pre", "");
|
|
314
|
+
root.append(head, body);
|
|
315
|
+
const pane = { root, status, target, branch, age: ageEl, body, follow: true, runId: null, t };
|
|
316
|
+
body.addEventListener("scroll", () => {
|
|
317
|
+
const atBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 24;
|
|
318
|
+
pane.follow = atBottom;
|
|
319
|
+
root.classList.toggle("is-paused", !atBottom);
|
|
320
|
+
});
|
|
321
|
+
paused.addEventListener("click", () => { pane.follow = true; root.classList.remove("is-paused"); body.scrollTop = body.scrollHeight; });
|
|
322
|
+
return pane;
|
|
323
|
+
}
|
|
324
|
+
function paneFor(t) {
|
|
325
|
+
let pane = panes.get(t.thread_id);
|
|
326
|
+
if (!pane) { pane = makePane(t); panes.set(t.thread_id, pane); }
|
|
327
|
+
pane.t = t;
|
|
328
|
+
pane.status.className = "status " + t.status;
|
|
329
|
+
pane.status.textContent = t.status;
|
|
330
|
+
pane.target.textContent = t.target;
|
|
331
|
+
pane.branch.textContent = t.branch;
|
|
332
|
+
pane.root.classList.toggle("muted", !t.live);
|
|
333
|
+
return pane;
|
|
334
|
+
}
|
|
335
|
+
function render() {
|
|
336
|
+
const shown = threads.filter((t) => t.live || expanded.has(t.thread_id));
|
|
337
|
+
const done = threads.filter((t) => !t.live && !expanded.has(t.thread_id));
|
|
338
|
+
const keep = new Set(shown.map((t) => t.thread_id));
|
|
339
|
+
for (const [id, pane] of panes) if (!keep.has(id)) pane.root.remove();
|
|
340
|
+
shown.forEach((t, index) => {
|
|
341
|
+
const pane = paneFor(t);
|
|
342
|
+
if (grid.children[index] !== pane.root) grid.insertBefore(pane.root, grid.children[index] || null);
|
|
343
|
+
});
|
|
344
|
+
strip.replaceChildren(...done.map((t) => {
|
|
345
|
+
paneFor(t);
|
|
346
|
+
const chip = el("span", "done", t.thread_id.slice(0, 8) + " " + t.target + " " + t.status + " " + age(t.finished_at || t.run_started_at) + " ago");
|
|
347
|
+
chip.title = t.branch + " (click to show log)";
|
|
348
|
+
chip.addEventListener("click", () => { expanded.add(t.thread_id); render(); });
|
|
349
|
+
return chip;
|
|
350
|
+
}));
|
|
351
|
+
empty.hidden = threads.some((t) => t.live);
|
|
352
|
+
tickAges();
|
|
353
|
+
}
|
|
354
|
+
function tickAges() {
|
|
355
|
+
for (const t of threads) {
|
|
356
|
+
const pane = panes.get(t.thread_id);
|
|
357
|
+
if (pane) pane.age.textContent = t.live ? age(t.run_started_at) : "done " + age(t.finished_at || t.run_started_at) + " ago";
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function append(msg) {
|
|
361
|
+
const pane = panes.get(msg.thread_id);
|
|
362
|
+
if (!pane) return;
|
|
363
|
+
if (msg.reset || pane.runId !== msg.run_id) { pane.body.textContent = ""; pane.runId = msg.run_id; }
|
|
364
|
+
let text = pane.body.textContent + msg.text;
|
|
365
|
+
if (text.length > MAX_CHARS) text = text.slice(text.length - MAX_CHARS);
|
|
366
|
+
pane.body.textContent = text;
|
|
367
|
+
if (pane.follow) pane.body.scrollTop = pane.body.scrollHeight;
|
|
368
|
+
}
|
|
369
|
+
function connect() {
|
|
370
|
+
const source = new EventSource("/events?token=" + encodeURIComponent(token));
|
|
371
|
+
source.onopen = () => { conn.textContent = "live"; conn.className = "meta"; };
|
|
372
|
+
source.onerror = () => { conn.textContent = "disconnected, retrying"; conn.className = "meta down"; };
|
|
373
|
+
source.addEventListener("threads", (event) => {
|
|
374
|
+
const data = JSON.parse(event.data);
|
|
375
|
+
skew = Date.parse(data.now) - Date.now();
|
|
376
|
+
document.getElementById("home").textContent = data.home;
|
|
377
|
+
threads = data.threads;
|
|
378
|
+
if (data.pinned) expanded.add(data.pinned);
|
|
379
|
+
render();
|
|
380
|
+
});
|
|
381
|
+
source.addEventListener("log", (event) => append(JSON.parse(event.data)));
|
|
382
|
+
}
|
|
383
|
+
setInterval(tickAges, 1000);
|
|
384
|
+
connect();
|
|
385
|
+
</script>
|
|
386
|
+
</body>
|
|
387
|
+
</html>
|
|
388
|
+
`;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { Discover } from "./model-cache.js";
|
|
1
2
|
import type { Limits } from "./limits.js";
|
|
2
3
|
import type { AdapterTarget, Finding } from "./adapters.js";
|
|
4
|
+
import type { RunUsage } from "./usage.js";
|
|
3
5
|
export type ThreadId = string & {
|
|
4
6
|
readonly __brand: "ThreadId";
|
|
5
7
|
};
|
|
@@ -94,6 +96,7 @@ export type RunSpec = {
|
|
|
94
96
|
deadlineMs?: number | null;
|
|
95
97
|
};
|
|
96
98
|
export type RunResult = {
|
|
99
|
+
usage: RunUsage;
|
|
97
100
|
kind: "completed" | "needs_input" | "failed" | "cancelled";
|
|
98
101
|
message: string;
|
|
99
102
|
sessionId: string | null;
|
|
@@ -125,7 +128,13 @@ export type SendResult = {
|
|
|
125
128
|
error: string;
|
|
126
129
|
};
|
|
127
130
|
export type ThreadStatus = "working" | "needs_input" | "succeeded" | "failed" | "cancelled";
|
|
128
|
-
export type
|
|
131
|
+
export type UsageFields = {
|
|
132
|
+
target: string;
|
|
133
|
+
effort: string | null;
|
|
134
|
+
model: string | null;
|
|
135
|
+
usage: Omit<RunUsage, "model" | "effort">;
|
|
136
|
+
};
|
|
137
|
+
export type InboxResult = (({
|
|
129
138
|
ok: true;
|
|
130
139
|
status: "working";
|
|
131
140
|
retry_after_seconds: number;
|
|
@@ -153,7 +162,7 @@ export type InboxResult = {
|
|
|
153
162
|
session_id: string | null;
|
|
154
163
|
findings: Finding[];
|
|
155
164
|
commit: string | null;
|
|
156
|
-
} | {
|
|
165
|
+
}) & UsageFields) | {
|
|
157
166
|
ok: false;
|
|
158
167
|
error: string;
|
|
159
168
|
};
|
|
@@ -178,4 +187,5 @@ export type MailboxContext = {
|
|
|
178
187
|
retryAfterSeconds?: number;
|
|
179
188
|
wrapperCommand?: string[];
|
|
180
189
|
nodeExecutable?: string;
|
|
190
|
+
discoverModels?: Discover;
|
|
181
191
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const REGISTRY_LATEST_URL = "https://registry.npmjs.org/outsrc/latest";
|
|
2
|
+
export type UpdateCheck = {
|
|
3
|
+
status: "current" | "outdated" | "ahead";
|
|
4
|
+
installed: string;
|
|
5
|
+
latest: string;
|
|
6
|
+
} | {
|
|
7
|
+
status: "unknown";
|
|
8
|
+
installed: string;
|
|
9
|
+
latest: null;
|
|
10
|
+
error: string;
|
|
11
|
+
};
|
|
12
|
+
export type FetchLatest = () => Promise<string>;
|
|
13
|
+
export declare function installedVersion(): string;
|
|
14
|
+
export declare function compareVersions(a: string, b: string): number | null;
|
|
15
|
+
export declare function registryFetchLatest(timeoutMs?: number): FetchLatest;
|
|
16
|
+
export declare function checkForUpdate(installed: string, fetchLatest: FetchLatest): Promise<UpdateCheck>;
|
|
17
|
+
export declare function updateAvailable(check: UpdateCheck): boolean;
|
|
18
|
+
export declare function describeUpdate(check: UpdateCheck): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const REGISTRY_LATEST_URL = "https://registry.npmjs.org/outsrc/latest";
|
|
4
|
+
export function installedVersion() {
|
|
5
|
+
// src/ and dist/ both sit one level below the package root, which npm always ships package.json in.
|
|
6
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
7
|
+
return z.object({ version: z.string() }).parse(pkg).version;
|
|
8
|
+
}
|
|
9
|
+
function parseVersion(version) {
|
|
10
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
|
11
|
+
if (!match)
|
|
12
|
+
return null;
|
|
13
|
+
return { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4]?.split(".") ?? [] };
|
|
14
|
+
}
|
|
15
|
+
function comparePre(a, b) {
|
|
16
|
+
if (!a.length || !b.length)
|
|
17
|
+
return Math.sign(b.length - a.length);
|
|
18
|
+
for (let index = 0; index < Math.max(a.length, b.length); index++) {
|
|
19
|
+
const x = a[index];
|
|
20
|
+
const y = b[index];
|
|
21
|
+
if (x === undefined)
|
|
22
|
+
return -1;
|
|
23
|
+
if (y === undefined)
|
|
24
|
+
return 1;
|
|
25
|
+
const xn = /^\d+$/.test(x);
|
|
26
|
+
const yn = /^\d+$/.test(y);
|
|
27
|
+
if (xn && yn && Number(x) !== Number(y))
|
|
28
|
+
return Number(x) < Number(y) ? -1 : 1;
|
|
29
|
+
if (xn !== yn)
|
|
30
|
+
return xn ? -1 : 1;
|
|
31
|
+
if (x !== y)
|
|
32
|
+
return x < y ? -1 : 1;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
// Semver precedence: negative when a < b, 0 when equal, positive when a > b. Null when either is not semver.
|
|
37
|
+
export function compareVersions(a, b) {
|
|
38
|
+
const x = parseVersion(a);
|
|
39
|
+
const y = parseVersion(b);
|
|
40
|
+
if (!x || !y)
|
|
41
|
+
return null;
|
|
42
|
+
for (let index = 0; index < 3; index++) {
|
|
43
|
+
if (x.core[index] !== y.core[index])
|
|
44
|
+
return x.core[index] < y.core[index] ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
return comparePre(x.pre, y.pre);
|
|
47
|
+
}
|
|
48
|
+
export function registryFetchLatest(timeoutMs = 3000) {
|
|
49
|
+
return async () => {
|
|
50
|
+
const response = await fetch(REGISTRY_LATEST_URL, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json" } });
|
|
51
|
+
if (!response.ok)
|
|
52
|
+
throw new Error(`registry returned HTTP ${response.status}`);
|
|
53
|
+
return z.object({ version: z.string() }).parse(await response.json()).version;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Never throws: a registry or network failure becomes status "unknown" so doctor still reports everything else.
|
|
57
|
+
export async function checkForUpdate(installed, fetchLatest) {
|
|
58
|
+
let latest;
|
|
59
|
+
try {
|
|
60
|
+
latest = await fetchLatest();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return { status: "unknown", installed, latest: null, error: error instanceof Error ? error.message : String(error) };
|
|
64
|
+
}
|
|
65
|
+
const order = compareVersions(installed, latest);
|
|
66
|
+
if (order === null)
|
|
67
|
+
return { status: "unknown", installed, latest: null, error: `cannot compare versions ${installed} and ${latest}` };
|
|
68
|
+
return { status: order < 0 ? "outdated" : order > 0 ? "ahead" : "current", installed, latest };
|
|
69
|
+
}
|
|
70
|
+
export function updateAvailable(check) {
|
|
71
|
+
return check.status === "outdated";
|
|
72
|
+
}
|
|
73
|
+
export function describeUpdate(check) {
|
|
74
|
+
switch (check.status) {
|
|
75
|
+
case "outdated":
|
|
76
|
+
return `outsrc ${check.installed} is installed; ${check.latest} is available. Upgrade with: npm install -g outsrc@latest`;
|
|
77
|
+
case "current":
|
|
78
|
+
return `outsrc ${check.installed} is the latest version.`;
|
|
79
|
+
case "ahead":
|
|
80
|
+
return `outsrc ${check.installed} is newer than the latest published version ${check.latest}.`;
|
|
81
|
+
case "unknown":
|
|
82
|
+
return `outsrc ${check.installed} is installed; could not check for updates (${check.error}).`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const PRODUCTS: readonly ["claude", "codex", "grok", "outsrc"];
|
|
3
|
+
export type Product = (typeof PRODUCTS)[number];
|
|
4
|
+
declare const Check: z.ZodObject<{
|
|
5
|
+
installed: z.ZodNullable<z.ZodString>;
|
|
6
|
+
latest: z.ZodNullable<z.ZodString>;
|
|
7
|
+
error: z.ZodNullable<z.ZodString>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export type Check = z.infer<typeof Check>;
|
|
10
|
+
declare const StateSchema: z.ZodObject<{
|
|
11
|
+
version: z.ZodLiteral<1>;
|
|
12
|
+
checked_at: z.ZodNullable<z.ZodString>;
|
|
13
|
+
checks: z.ZodRecord<z.ZodEnum<{
|
|
14
|
+
claude: "claude";
|
|
15
|
+
codex: "codex";
|
|
16
|
+
grok: "grok";
|
|
17
|
+
outsrc: "outsrc";
|
|
18
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
19
|
+
installed: z.ZodNullable<z.ZodString>;
|
|
20
|
+
latest: z.ZodNullable<z.ZodString>;
|
|
21
|
+
error: z.ZodNullable<z.ZodString>;
|
|
22
|
+
}, z.core.$strip>>;
|
|
23
|
+
pending: z.ZodRecord<z.ZodEnum<{
|
|
24
|
+
claude: "claude";
|
|
25
|
+
codex: "codex";
|
|
26
|
+
grok: "grok";
|
|
27
|
+
outsrc: "outsrc";
|
|
28
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
29
|
+
installed: z.ZodString;
|
|
30
|
+
latest: z.ZodString;
|
|
31
|
+
first_seen_at: z.ZodString;
|
|
32
|
+
}, z.core.$strip>>;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
export type UpdatesState = z.infer<typeof StateSchema>;
|
|
35
|
+
/** One pending update. id is "<product>@<latest>", so each new latest version is announced once. */
|
|
36
|
+
export type Notification = {
|
|
37
|
+
id: string;
|
|
38
|
+
product: Product;
|
|
39
|
+
installed: string;
|
|
40
|
+
latest: string;
|
|
41
|
+
first_seen_at: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function updatesFile(home: string): string;
|
|
44
|
+
export declare function ackFile(home: string): string;
|
|
45
|
+
export declare function readUpdates(home: string): UpdatesState;
|
|
46
|
+
export declare function notifications(state: UpdatesState): Notification[];
|
|
47
|
+
/** Pending updates the owner has not acknowledged yet. */
|
|
48
|
+
export declare function unread(home: string, state?: {
|
|
49
|
+
version: 1;
|
|
50
|
+
checked_at: string | null;
|
|
51
|
+
checks: Partial<Record<"claude" | "codex" | "grok" | "outsrc", {
|
|
52
|
+
installed: string | null;
|
|
53
|
+
latest: string | null;
|
|
54
|
+
error: string | null;
|
|
55
|
+
}>>;
|
|
56
|
+
pending: Partial<Record<"claude" | "codex" | "grok" | "outsrc", {
|
|
57
|
+
installed: string;
|
|
58
|
+
latest: string;
|
|
59
|
+
first_seen_at: string;
|
|
60
|
+
}>>;
|
|
61
|
+
}): Notification[];
|
|
62
|
+
export declare function ack(home: string, ids: string[] | "all"): string[];
|
|
63
|
+
export type Probe = {
|
|
64
|
+
installed(product: Product): Promise<string>;
|
|
65
|
+
latest(product: Product): Promise<string>;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Checks every product and records which are behind. Never installs anything.
|
|
69
|
+
* A pending entry keeps its first_seen_at until a different latest version appears, so reruns are idempotent,
|
|
70
|
+
* and a failed check keeps the previous entry instead of dropping it and announcing it again later.
|
|
71
|
+
*/
|
|
72
|
+
export declare function refresh(home: string, probe: Probe, now?: Date): Promise<{
|
|
73
|
+
state: UpdatesState;
|
|
74
|
+
fresh: Notification[];
|
|
75
|
+
}>;
|
|
76
|
+
/** Version queries only: `<cli> --version`, the npm registry, and `grok update --check`. No model calls. */
|
|
77
|
+
export declare const defaultProbe: Probe;
|
|
78
|
+
export {};
|