@byfriends/vis-server 0.3.4
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/LICENSE +28 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +25 -0
- package/dist/public/assets/index-B-eI7eKY.js +5 -0
- package/dist/public/assets/index-C_tCz85D.css +1 -0
- package/dist/public/assets/vendor-react-CNwmIw-M.js +299 -0
- package/dist/public/assets/vendor-tanstack-CmEBlPtt.js +4 -0
- package/dist/public/index.html +42 -0
- package/dist/server-CqbNDUBH.mjs +1044 -0
- package/dist/server.d.mts +49 -0
- package/dist/server.mjs +2 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { readFile, readdir, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { join, resolve, sep } from "node:path";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
//#region src/config.ts
|
|
11
|
+
/** Resolve BYF_HOME (env > ~/.byf). */
|
|
12
|
+
function resolveByfHome() {
|
|
13
|
+
const envHome = process.env["BYF_HOME"];
|
|
14
|
+
if (envHome !== void 0 && envHome.length > 0) return envHome;
|
|
15
|
+
return join(homedir(), ".byf");
|
|
16
|
+
}
|
|
17
|
+
/** HTTP port for the vis API server. */
|
|
18
|
+
function resolvePort() {
|
|
19
|
+
const raw = process.env["PORT"];
|
|
20
|
+
if (raw !== void 0 && raw.length > 0) {
|
|
21
|
+
const n = Number.parseInt(raw, 10);
|
|
22
|
+
if (Number.isFinite(n) && n > 0 && n < 65536) return n;
|
|
23
|
+
}
|
|
24
|
+
return 3001;
|
|
25
|
+
}
|
|
26
|
+
/** HTTP host for the vis API server. Defaults to loopback. */
|
|
27
|
+
function resolveHost() {
|
|
28
|
+
const host = (process.env["VIS_HOST"] ?? process.env["HOST"])?.trim();
|
|
29
|
+
return host !== void 0 && host.length > 0 ? host : "127.0.0.1";
|
|
30
|
+
}
|
|
31
|
+
function isLoopbackHost(host) {
|
|
32
|
+
const normalized = host.trim().toLowerCase().replaceAll("[", "").replaceAll("]", "");
|
|
33
|
+
return normalized === "localhost" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1" || normalized.startsWith("127.");
|
|
34
|
+
}
|
|
35
|
+
function resolveVisAuthToken(host = resolveHost()) {
|
|
36
|
+
const token = process.env["VIS_AUTH_TOKEN"]?.trim();
|
|
37
|
+
if (token !== void 0 && token.length > 0) return token;
|
|
38
|
+
if (!isLoopbackHost(host)) throw new Error(`VIS_AUTH_TOKEN is required when binding vis-server outside loopback (host=${host})`);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/lib/context-projector.ts
|
|
42
|
+
const ZERO = {
|
|
43
|
+
inputOther: 0,
|
|
44
|
+
output: 0,
|
|
45
|
+
inputCacheRead: 0,
|
|
46
|
+
inputCacheCreation: 0
|
|
47
|
+
};
|
|
48
|
+
/** Build a conversation timeline + derived state from a sequence of
|
|
49
|
+
* wire entries. The reconstruction mirrors agent-core's own
|
|
50
|
+
* `appendLoopEvent` logic, so:
|
|
51
|
+
*
|
|
52
|
+
* - `context.append_message` records become messages as-is (the
|
|
53
|
+
* user / tool messages and any explicit assistant injections).
|
|
54
|
+
* - `step.begin` pushes a fresh assistant message; later
|
|
55
|
+
* `content.part` and `tool.call` events on the same step **mutate
|
|
56
|
+
* that same message** to grow its content / toolCalls. `step.end`
|
|
57
|
+
* just closes the step.
|
|
58
|
+
* - `tool.result` events emit an independent `role: 'tool'` message,
|
|
59
|
+
* matching how agent-core surfaces tool exchanges to the model.
|
|
60
|
+
*
|
|
61
|
+
* Without this loop-event reconstruction the timeline would only
|
|
62
|
+
* show user prompts — agent-core does not emit a synthetic
|
|
63
|
+
* `context.append_message` for assistant turns. */
|
|
64
|
+
function projectContext(entries) {
|
|
65
|
+
let messages = [];
|
|
66
|
+
const usage = {
|
|
67
|
+
byScope: {
|
|
68
|
+
session: { ...ZERO },
|
|
69
|
+
turn: { ...ZERO }
|
|
70
|
+
},
|
|
71
|
+
byModel: {}
|
|
72
|
+
};
|
|
73
|
+
const config = {};
|
|
74
|
+
let permissionMode = null;
|
|
75
|
+
let openSteps = /* @__PURE__ */ new Map();
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const rec = entry.data;
|
|
78
|
+
switch (rec.type) {
|
|
79
|
+
case "context.append_message":
|
|
80
|
+
messages.push({
|
|
81
|
+
lineNo: entry.lineNo,
|
|
82
|
+
time: rec.time,
|
|
83
|
+
source: "append_message",
|
|
84
|
+
message: rec.message,
|
|
85
|
+
toolStepUuids: []
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
case "context.append_loop_event": {
|
|
89
|
+
const ev = rec.event;
|
|
90
|
+
if (ev.type === "step.begin") {
|
|
91
|
+
const projected = {
|
|
92
|
+
lineNo: entry.lineNo,
|
|
93
|
+
time: rec.time,
|
|
94
|
+
source: "append_message",
|
|
95
|
+
message: {
|
|
96
|
+
role: "assistant",
|
|
97
|
+
content: [],
|
|
98
|
+
toolCalls: []
|
|
99
|
+
},
|
|
100
|
+
toolStepUuids: [ev.uuid]
|
|
101
|
+
};
|
|
102
|
+
messages.push(projected);
|
|
103
|
+
openSteps.set(ev.uuid, projected);
|
|
104
|
+
} else if (ev.type === "content.part") {
|
|
105
|
+
const projected = openSteps.get(ev.stepUuid);
|
|
106
|
+
if (projected !== void 0) projected.message.content.push(ev.part);
|
|
107
|
+
} else if (ev.type === "tool.call") {
|
|
108
|
+
const projected = openSteps.get(ev.stepUuid);
|
|
109
|
+
if (projected !== void 0) {
|
|
110
|
+
const args = typeof ev.args === "string" ? ev.args : ev.args === void 0 ? null : JSON.stringify(ev.args);
|
|
111
|
+
projected.message.toolCalls.push({
|
|
112
|
+
type: "function",
|
|
113
|
+
id: ev.toolCallId,
|
|
114
|
+
name: ev.name,
|
|
115
|
+
arguments: args
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} else if (ev.type === "step.end") openSteps.delete(ev.uuid);
|
|
119
|
+
else if (ev.type === "tool.result") {
|
|
120
|
+
const output = ev.result.output;
|
|
121
|
+
const toolMsg = {
|
|
122
|
+
role: "tool",
|
|
123
|
+
content: typeof output === "string" ? [{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: output
|
|
126
|
+
}] : output,
|
|
127
|
+
toolCalls: [],
|
|
128
|
+
toolCallId: ev.toolCallId,
|
|
129
|
+
...ev.result.isError === true ? { isError: true } : {}
|
|
130
|
+
};
|
|
131
|
+
messages.push({
|
|
132
|
+
lineNo: entry.lineNo,
|
|
133
|
+
time: rec.time,
|
|
134
|
+
source: "append_message",
|
|
135
|
+
message: toolMsg,
|
|
136
|
+
toolStepUuids: []
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "context.clear":
|
|
142
|
+
messages = [];
|
|
143
|
+
openSteps = /* @__PURE__ */ new Map();
|
|
144
|
+
break;
|
|
145
|
+
case "context.apply_compaction":
|
|
146
|
+
openSteps = /* @__PURE__ */ new Map();
|
|
147
|
+
messages = [{
|
|
148
|
+
lineNo: entry.lineNo,
|
|
149
|
+
time: rec.time,
|
|
150
|
+
source: "compaction_summary",
|
|
151
|
+
message: {
|
|
152
|
+
role: "assistant",
|
|
153
|
+
content: [{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: rec.summary
|
|
156
|
+
}],
|
|
157
|
+
toolCalls: [],
|
|
158
|
+
origin: { kind: "compaction_summary" }
|
|
159
|
+
},
|
|
160
|
+
toolStepUuids: []
|
|
161
|
+
}];
|
|
162
|
+
break;
|
|
163
|
+
case "usage.record": {
|
|
164
|
+
const scope = rec.usageScope ?? "session";
|
|
165
|
+
addUsage(usage.byScope[scope], rec.usage);
|
|
166
|
+
usage.byModel[rec.model] ??= { ...ZERO };
|
|
167
|
+
addUsage(usage.byModel[rec.model], rec.usage);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "config.update": {
|
|
171
|
+
const typeChecked = rec;
|
|
172
|
+
if (typeChecked.cwd !== void 0) config.cwd = typeChecked.cwd;
|
|
173
|
+
if (typeChecked.modelAlias !== void 0) config.modelAlias = typeChecked.modelAlias;
|
|
174
|
+
if (typeChecked.profileName !== void 0) config.profileName = typeChecked.profileName;
|
|
175
|
+
if (typeChecked.thinkingLevel !== void 0) config.thinkingLevel = typeChecked.thinkingLevel;
|
|
176
|
+
if (typeChecked.systemPrompt !== void 0) config.systemPrompt = typeChecked.systemPrompt;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "permission.set_mode":
|
|
180
|
+
permissionMode = rec.mode;
|
|
181
|
+
break;
|
|
182
|
+
default: break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
messages,
|
|
187
|
+
usage,
|
|
188
|
+
config,
|
|
189
|
+
permission: { mode: permissionMode }
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function addUsage(into, src) {
|
|
193
|
+
into.inputOther += src.inputOther;
|
|
194
|
+
into.output += src.output;
|
|
195
|
+
into.inputCacheRead += src.inputCacheRead;
|
|
196
|
+
into.inputCacheCreation += src.inputCacheCreation;
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/lib/session-store.ts
|
|
200
|
+
const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
|
|
201
|
+
const AGENT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
202
|
+
/** Reject agent ids that could escape the session directory via path
|
|
203
|
+
* joins. Defence-in-depth: the on-disk source of these ids is
|
|
204
|
+
* agent-core (which only generates main / agent-N), but a corrupted
|
|
205
|
+
* or hand-edited `state.json.agents` key could otherwise turn vis
|
|
206
|
+
* into a local-file-read primitive when exposed beyond loopback. */
|
|
207
|
+
function isSafeAgentId(id) {
|
|
208
|
+
return AGENT_ID_RE.test(id) && id !== "." && id !== "..";
|
|
209
|
+
}
|
|
210
|
+
async function listSessions(home) {
|
|
211
|
+
const sessionsDir = join(home, "sessions");
|
|
212
|
+
const buckets = await readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
|
213
|
+
const index = await readSessionIndex(home);
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const bucket of buckets) {
|
|
216
|
+
if (!bucket.isDirectory()) continue;
|
|
217
|
+
const bucketDir = join(sessionsDir, bucket.name);
|
|
218
|
+
const sessionDirs = await readdir(bucketDir, { withFileTypes: true }).catch(() => []);
|
|
219
|
+
for (const entry of sessionDirs) {
|
|
220
|
+
if (!entry.isDirectory() || !SESSION_ID_RE.test(entry.name)) continue;
|
|
221
|
+
const sessionDir = join(bucketDir, entry.name);
|
|
222
|
+
const workDir = index.get(entry.name)?.workDir ?? "";
|
|
223
|
+
const summary = await tryReadSummary(sessionDir, entry.name, workDir);
|
|
224
|
+
if (summary !== null) out.push(summary);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
async function readSessionDetail(home, sessionId) {
|
|
231
|
+
const sessionDir = await findSessionDir(home, sessionId);
|
|
232
|
+
if (sessionDir === null) return null;
|
|
233
|
+
const workDir = (await readSessionIndex(home)).get(sessionId)?.workDir ?? "";
|
|
234
|
+
const state = await readState(sessionDir);
|
|
235
|
+
if (state === null) return {
|
|
236
|
+
sessionId,
|
|
237
|
+
sessionDir,
|
|
238
|
+
workDir,
|
|
239
|
+
state: null,
|
|
240
|
+
agents: await discoverAgentsFromDisk(sessionDir)
|
|
241
|
+
};
|
|
242
|
+
if (state.custom?.["imported_from_byf_cli"] === true) return null;
|
|
243
|
+
return {
|
|
244
|
+
sessionId,
|
|
245
|
+
sessionDir,
|
|
246
|
+
workDir,
|
|
247
|
+
state,
|
|
248
|
+
agents: await inventoryAgents(sessionDir, state)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Fallback inventory used when `state.json` is unreadable: walk
|
|
252
|
+
* `<sessionDir>/agents/*` directly and synthesize minimal AgentInfo
|
|
253
|
+
* records for the directories that contain a `wire.jsonl`. Parent
|
|
254
|
+
* links and `type` are unknown without state, so we mark every agent
|
|
255
|
+
* as `independent` with a null parent — the routes only need
|
|
256
|
+
* `agentId` + `wireExists` to serve wire/context. */
|
|
257
|
+
async function discoverAgentsFromDisk(sessionDir) {
|
|
258
|
+
const agentsDir = join(sessionDir, "agents");
|
|
259
|
+
let entries;
|
|
260
|
+
try {
|
|
261
|
+
entries = await readdir(agentsDir, { withFileTypes: true });
|
|
262
|
+
} catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
if (!entry.isDirectory()) continue;
|
|
268
|
+
const id = entry.name;
|
|
269
|
+
if (!isSafeAgentId(id)) continue;
|
|
270
|
+
const wirePath = join(agentsDir, id, "wire.jsonl");
|
|
271
|
+
const exists = await pathExists(wirePath);
|
|
272
|
+
let readable = exists;
|
|
273
|
+
let info = {
|
|
274
|
+
count: 0,
|
|
275
|
+
protocolVersion: null
|
|
276
|
+
};
|
|
277
|
+
if (exists) try {
|
|
278
|
+
info = await scanWire(wirePath);
|
|
279
|
+
} catch {
|
|
280
|
+
readable = false;
|
|
281
|
+
}
|
|
282
|
+
out.push({
|
|
283
|
+
agentId: id,
|
|
284
|
+
type: id === "main" ? "main" : "independent",
|
|
285
|
+
parentAgentId: null,
|
|
286
|
+
homedir: join(agentsDir, id),
|
|
287
|
+
wireExists: readable,
|
|
288
|
+
wireRecordCount: info.count,
|
|
289
|
+
wireProtocolVersion: info.protocolVersion
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return out.toSorted((a, b) => {
|
|
293
|
+
if (a.agentId === "main") return -1;
|
|
294
|
+
if (b.agentId === "main") return 1;
|
|
295
|
+
return a.agentId.localeCompare(b.agentId);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async function tryReadSummary(sessionDir, sessionId, workDir) {
|
|
299
|
+
const state = await readState(sessionDir);
|
|
300
|
+
if (state === null) return brokenStateSummary(sessionDir, sessionId, workDir);
|
|
301
|
+
if (state.custom?.["imported_from_byf_cli"] === true) return null;
|
|
302
|
+
const mainWirePath = join(sessionDir, "agents", "main", "wire.jsonl");
|
|
303
|
+
const mainExists = await pathExists(mainWirePath);
|
|
304
|
+
let mainCount = 0;
|
|
305
|
+
let protocolVersion = null;
|
|
306
|
+
let health = "ok";
|
|
307
|
+
if (!mainExists) health = "missing_main_wire";
|
|
308
|
+
else try {
|
|
309
|
+
const info = await scanWire(mainWirePath);
|
|
310
|
+
mainCount = info.count;
|
|
311
|
+
protocolVersion = info.protocolVersion;
|
|
312
|
+
} catch {
|
|
313
|
+
health = "broken_main_wire";
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
sessionId,
|
|
317
|
+
sessionDir,
|
|
318
|
+
workDir,
|
|
319
|
+
title: state.title ?? null,
|
|
320
|
+
lastPrompt: state.lastPrompt ?? null,
|
|
321
|
+
isCustomTitle: state.isCustomTitle ?? false,
|
|
322
|
+
createdAt: parseTs(state.createdAt),
|
|
323
|
+
updatedAt: parseTs(state.updatedAt),
|
|
324
|
+
agentCount: Object.keys(state.agents ?? {}).length,
|
|
325
|
+
mainAgentExists: mainExists,
|
|
326
|
+
mainWireRecordCount: mainCount,
|
|
327
|
+
wireProtocolVersion: protocolVersion,
|
|
328
|
+
health
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function brokenStateSummary(sessionDir, sessionId, workDir) {
|
|
332
|
+
return {
|
|
333
|
+
sessionId,
|
|
334
|
+
sessionDir,
|
|
335
|
+
workDir,
|
|
336
|
+
title: null,
|
|
337
|
+
lastPrompt: null,
|
|
338
|
+
isCustomTitle: false,
|
|
339
|
+
createdAt: 0,
|
|
340
|
+
updatedAt: 0,
|
|
341
|
+
agentCount: 0,
|
|
342
|
+
mainAgentExists: false,
|
|
343
|
+
mainWireRecordCount: 0,
|
|
344
|
+
wireProtocolVersion: null,
|
|
345
|
+
health: "broken_state"
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async function readSessionIndex(home) {
|
|
349
|
+
const out = /* @__PURE__ */ new Map();
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = await readFile(join(home, "session_index.jsonl"), "utf8");
|
|
353
|
+
} catch {
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
357
|
+
if (!line.trim()) continue;
|
|
358
|
+
try {
|
|
359
|
+
const entry = JSON.parse(line);
|
|
360
|
+
if (typeof entry.sessionId === "string" && typeof entry.sessionDir === "string") out.set(entry.sessionId, {
|
|
361
|
+
sessionDir: entry.sessionDir,
|
|
362
|
+
workDir: typeof entry.workDir === "string" ? entry.workDir : ""
|
|
363
|
+
});
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
async function inventoryAgents(sessionDir, state) {
|
|
369
|
+
const result = [];
|
|
370
|
+
for (const [id, meta] of Object.entries(state.agents ?? {})) {
|
|
371
|
+
if (!isSafeAgentId(id)) continue;
|
|
372
|
+
const wirePath = join(sessionDir, "agents", id, "wire.jsonl");
|
|
373
|
+
const exists = await pathExists(wirePath);
|
|
374
|
+
let readable = exists;
|
|
375
|
+
let info = {
|
|
376
|
+
count: 0,
|
|
377
|
+
protocolVersion: null
|
|
378
|
+
};
|
|
379
|
+
if (exists) try {
|
|
380
|
+
info = await scanWire(wirePath);
|
|
381
|
+
} catch {
|
|
382
|
+
readable = false;
|
|
383
|
+
}
|
|
384
|
+
result.push({
|
|
385
|
+
agentId: id,
|
|
386
|
+
type: meta.type,
|
|
387
|
+
parentAgentId: meta.parentAgentId,
|
|
388
|
+
homedir: meta.homedir,
|
|
389
|
+
wireExists: readable,
|
|
390
|
+
wireRecordCount: info.count,
|
|
391
|
+
wireProtocolVersion: info.protocolVersion
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return result.toSorted((a, b) => {
|
|
395
|
+
if (a.agentId === "main") return -1;
|
|
396
|
+
if (b.agentId === "main") return 1;
|
|
397
|
+
return a.agentId.localeCompare(b.agentId);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function readState(sessionDir) {
|
|
401
|
+
try {
|
|
402
|
+
return JSON.parse(await readFile(join(sessionDir, "state.json"), "utf8"));
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function findSessionDir(home, sessionId) {
|
|
408
|
+
if (!SESSION_ID_RE.test(sessionId)) return null;
|
|
409
|
+
const sessionsRoot = resolve(join(home, "sessions"));
|
|
410
|
+
const sessionsRootPrefix = sessionsRoot + sep;
|
|
411
|
+
try {
|
|
412
|
+
const indexLines = (await readFile(join(home, "session_index.jsonl"), "utf8")).split(/\r?\n/);
|
|
413
|
+
for (const line of indexLines) {
|
|
414
|
+
if (!line.trim()) continue;
|
|
415
|
+
const entry = JSON.parse(line);
|
|
416
|
+
if (entry.sessionId !== sessionId || typeof entry.sessionDir !== "string") continue;
|
|
417
|
+
const candidate = resolve(entry.sessionDir);
|
|
418
|
+
if (!candidate.startsWith(sessionsRootPrefix)) continue;
|
|
419
|
+
if (candidate.split(sep).pop() !== sessionId) continue;
|
|
420
|
+
if (await pathExists(candidate)) return candidate;
|
|
421
|
+
}
|
|
422
|
+
} catch {}
|
|
423
|
+
const buckets = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []);
|
|
424
|
+
for (const bucket of buckets) {
|
|
425
|
+
if (!bucket.isDirectory()) continue;
|
|
426
|
+
const candidate = join(sessionsRoot, bucket.name, sessionId);
|
|
427
|
+
if (await pathExists(candidate)) return candidate;
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
async function scanWire(path) {
|
|
432
|
+
const rl = createInterface({
|
|
433
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
434
|
+
crlfDelay: Infinity
|
|
435
|
+
});
|
|
436
|
+
let count = 0;
|
|
437
|
+
let protocolVersion = null;
|
|
438
|
+
for await (const line of rl) {
|
|
439
|
+
if (line.length === 0) continue;
|
|
440
|
+
if (protocolVersion === null) {
|
|
441
|
+
let parsed;
|
|
442
|
+
try {
|
|
443
|
+
parsed = JSON.parse(line);
|
|
444
|
+
} catch {
|
|
445
|
+
throw new Error(`wire metadata is not valid JSON at line 1`);
|
|
446
|
+
}
|
|
447
|
+
if (parsed.type !== "metadata" || typeof parsed.protocol_version !== "string") throw new Error(`wire is missing a metadata header on line 1`);
|
|
448
|
+
protocolVersion = parsed.protocol_version;
|
|
449
|
+
}
|
|
450
|
+
count += 1;
|
|
451
|
+
}
|
|
452
|
+
if (protocolVersion === null) throw new Error("wire file is empty");
|
|
453
|
+
return {
|
|
454
|
+
count,
|
|
455
|
+
protocolVersion
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function parseTs(input) {
|
|
459
|
+
if (!input) return 0;
|
|
460
|
+
const n = Date.parse(input);
|
|
461
|
+
return Number.isFinite(n) ? n : 0;
|
|
462
|
+
}
|
|
463
|
+
async function pathExists(p) {
|
|
464
|
+
try {
|
|
465
|
+
await stat(p);
|
|
466
|
+
return true;
|
|
467
|
+
} catch {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region ../../../packages/agent-core/src/agent/records/migration/v1.1.ts
|
|
473
|
+
function isLegacyToolCall(v) {
|
|
474
|
+
if (!isRecord(v)) return false;
|
|
475
|
+
return v["type"] === "function" && typeof v["id"] === "string" && isRecord(v["function"]);
|
|
476
|
+
}
|
|
477
|
+
function migrateToolCall(v) {
|
|
478
|
+
const { function: fn, ...rest } = v;
|
|
479
|
+
return {
|
|
480
|
+
...rest,
|
|
481
|
+
name: fn.name,
|
|
482
|
+
arguments: fn.arguments
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function isRecord(value) {
|
|
486
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
487
|
+
}
|
|
488
|
+
const MIGRATIONS = [{
|
|
489
|
+
sourceVersion: "1.0",
|
|
490
|
+
targetVersion: "1.1",
|
|
491
|
+
migrateRecord(record) {
|
|
492
|
+
if (record.type !== "context.append_message") return record;
|
|
493
|
+
const message = record["message"];
|
|
494
|
+
let changed = false;
|
|
495
|
+
const toolCalls = message.toolCalls.map((toolCall) => {
|
|
496
|
+
if (!isLegacyToolCall(toolCall)) return toolCall;
|
|
497
|
+
changed = true;
|
|
498
|
+
return migrateToolCall(toolCall);
|
|
499
|
+
});
|
|
500
|
+
if (!changed) return record;
|
|
501
|
+
return {
|
|
502
|
+
...record,
|
|
503
|
+
message: {
|
|
504
|
+
...message,
|
|
505
|
+
toolCalls
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}];
|
|
510
|
+
function resolveWireMigrations(readVersion) {
|
|
511
|
+
if (compareWireVersions(readVersion, "1.1") >= 0) return [];
|
|
512
|
+
const migrations = [];
|
|
513
|
+
let version = readVersion;
|
|
514
|
+
while (compareWireVersions(version, "1.1") < 0) {
|
|
515
|
+
const migration = findMigration(version);
|
|
516
|
+
if (migration === void 0) throw new Error(`Missing wire migration for version ${version}`);
|
|
517
|
+
migrations.push(migration);
|
|
518
|
+
version = migration.targetVersion;
|
|
519
|
+
}
|
|
520
|
+
return migrations;
|
|
521
|
+
}
|
|
522
|
+
function migrateWireRecord(record, migrations) {
|
|
523
|
+
return migrations.reduce((current, migration) => migration.migrateRecord(current), record);
|
|
524
|
+
}
|
|
525
|
+
function findMigration(sourceVersion) {
|
|
526
|
+
for (const migration of MIGRATIONS) if (migration.sourceVersion === sourceVersion) return migration;
|
|
527
|
+
}
|
|
528
|
+
function compareWireVersions(a, b) {
|
|
529
|
+
const partsA = a.split(".");
|
|
530
|
+
const partsB = b.split(".");
|
|
531
|
+
const maxLength = Math.max(partsA.length, partsB.length);
|
|
532
|
+
for (let i = 0; i < maxLength; i++) {
|
|
533
|
+
const diff = Number(partsA[i] ?? "0") - Number(partsB[i] ?? "0");
|
|
534
|
+
if (diff !== 0) return diff;
|
|
535
|
+
}
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/lib/wire-reader.ts
|
|
540
|
+
/** Best-effort fallback when a wire file declares a protocol_version that
|
|
541
|
+
* `agent-core` does not know about (e.g. the historic "2.2" alias that
|
|
542
|
+
* pre-dates the 1.x renumber). We try to apply the chain *starting* from
|
|
543
|
+
* the oldest known version (1.0) and warn the caller. If even that fails
|
|
544
|
+
* we just pass records through unchanged. */
|
|
545
|
+
function bestEffortMigrations() {
|
|
546
|
+
try {
|
|
547
|
+
return resolveWireMigrations("1.0");
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/** Read a single agent's `wire.jsonl`.
|
|
553
|
+
*
|
|
554
|
+
* Each record is returned as a `WireEntry` containing both the
|
|
555
|
+
* on-disk parsed form (`raw`) and the migrated current-protocol form
|
|
556
|
+
* (`data`). For wires that declare a protocol version `agent-core`
|
|
557
|
+
* does not recognise (historic 2.x labels, or truly future versions),
|
|
558
|
+
* the reader falls back to a best-effort path: records are run
|
|
559
|
+
* through the 1.0-onwards migration chain and a warning is added to
|
|
560
|
+
* `warnings[]` so the UI can surface the caveat. */
|
|
561
|
+
async function readAgentWire(path) {
|
|
562
|
+
const rl = createInterface({
|
|
563
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
564
|
+
crlfDelay: Infinity
|
|
565
|
+
});
|
|
566
|
+
let lineNo = 0;
|
|
567
|
+
let metadata = null;
|
|
568
|
+
let migrations = [];
|
|
569
|
+
const records = [];
|
|
570
|
+
const warnings = [];
|
|
571
|
+
for await (const line of rl) {
|
|
572
|
+
lineNo += 1;
|
|
573
|
+
if (line.length === 0) continue;
|
|
574
|
+
let parsed;
|
|
575
|
+
try {
|
|
576
|
+
parsed = JSON.parse(line);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
warnings.push(`line ${lineNo}: invalid JSON (${error.message})`);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (!isObject(parsed) || typeof parsed.type !== "string") {
|
|
582
|
+
warnings.push(`line ${lineNo}: missing 'type' field`);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (metadata === null) {
|
|
586
|
+
if (parsed.type !== "metadata") throw new Error(`Wire file missing metadata header at line ${lineNo}`);
|
|
587
|
+
const pv = parsed["protocol_version"];
|
|
588
|
+
const ca = parsed["created_at"];
|
|
589
|
+
if (typeof pv !== "string" || typeof ca !== "number") throw new TypeError(`Wire metadata malformed at line ${lineNo}`);
|
|
590
|
+
try {
|
|
591
|
+
migrations = resolveWireMigrations(pv);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
warnings.push(`unrecognised protocol_version "${pv}" — parsing as best-effort (${error.message})`);
|
|
594
|
+
migrations = bestEffortMigrations();
|
|
595
|
+
}
|
|
596
|
+
metadata = {
|
|
597
|
+
protocolVersion: pv,
|
|
598
|
+
createdAt: ca
|
|
599
|
+
};
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const raw = parsed;
|
|
603
|
+
let migrated;
|
|
604
|
+
try {
|
|
605
|
+
migrated = migrations.length === 0 ? raw : migrateWireRecord(raw, migrations);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
warnings.push(`line ${lineNo}: migration failed (${error.message}); using raw record`);
|
|
608
|
+
migrated = raw;
|
|
609
|
+
}
|
|
610
|
+
records.push({
|
|
611
|
+
lineNo,
|
|
612
|
+
data: migrated,
|
|
613
|
+
raw
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (metadata === null) throw new Error("Wire file is empty (no metadata)");
|
|
617
|
+
return {
|
|
618
|
+
metadata,
|
|
619
|
+
records,
|
|
620
|
+
warnings
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function isObject(v) {
|
|
624
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/routes/context.ts
|
|
628
|
+
function contextRoute() {
|
|
629
|
+
const r = new Hono();
|
|
630
|
+
r.get("/:id/context", async (c) => {
|
|
631
|
+
const id = c.req.param("id");
|
|
632
|
+
const agentId = c.req.query("agent") ?? "main";
|
|
633
|
+
if (!isSafeAgentId(agentId)) return c.json({
|
|
634
|
+
error: "invalid agent id",
|
|
635
|
+
code: "BAD_REQUEST"
|
|
636
|
+
}, 400);
|
|
637
|
+
const detail = await readSessionDetail(resolveByfHome(), id);
|
|
638
|
+
if (!detail) return c.json({
|
|
639
|
+
error: "session not found",
|
|
640
|
+
code: "NOT_FOUND"
|
|
641
|
+
}, 404);
|
|
642
|
+
const agent = detail.agents.find((a) => a.agentId === agentId);
|
|
643
|
+
if (!agent || !agent.wireExists) return c.json({
|
|
644
|
+
error: "agent wire not found",
|
|
645
|
+
code: "NOT_FOUND"
|
|
646
|
+
}, 404);
|
|
647
|
+
try {
|
|
648
|
+
const proj = projectContext((await readAgentWire(join(detail.sessionDir, "agents", agentId, "wire.jsonl"))).records);
|
|
649
|
+
return c.json({
|
|
650
|
+
sessionId: id,
|
|
651
|
+
agentId,
|
|
652
|
+
messages: proj.messages,
|
|
653
|
+
usage: proj.usage,
|
|
654
|
+
config: proj.config,
|
|
655
|
+
permission: proj.permission
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
const msg = error.message;
|
|
659
|
+
if (msg.toLowerCase().includes("unsupported protocol")) return c.json({
|
|
660
|
+
error: msg,
|
|
661
|
+
code: "UNSUPPORTED_PROTOCOL"
|
|
662
|
+
}, 400);
|
|
663
|
+
return c.json({
|
|
664
|
+
error: msg,
|
|
665
|
+
code: "READ_ERROR"
|
|
666
|
+
}, 500);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
return r;
|
|
670
|
+
}
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/routes/session-detail.ts
|
|
673
|
+
function sessionDetailRoute() {
|
|
674
|
+
const r = new Hono();
|
|
675
|
+
r.get("/:id", async (c) => {
|
|
676
|
+
const id = c.req.param("id");
|
|
677
|
+
const detail = await readSessionDetail(resolveByfHome(), id);
|
|
678
|
+
if (!detail) return c.json({
|
|
679
|
+
error: "session not found",
|
|
680
|
+
code: "NOT_FOUND"
|
|
681
|
+
}, 404);
|
|
682
|
+
return c.json(detail);
|
|
683
|
+
});
|
|
684
|
+
return r;
|
|
685
|
+
}
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/lib/reveal.ts
|
|
688
|
+
/** Resolve the platform-specific "reveal in file manager" command for
|
|
689
|
+
* the given absolute path. Kept pure (no IO) so it can be unit-tested. */
|
|
690
|
+
function revealCommandFor(path, platform = process.platform) {
|
|
691
|
+
switch (platform) {
|
|
692
|
+
case "darwin": return {
|
|
693
|
+
command: "open",
|
|
694
|
+
args: [path]
|
|
695
|
+
};
|
|
696
|
+
case "win32": return {
|
|
697
|
+
command: "cmd",
|
|
698
|
+
args: [
|
|
699
|
+
"/c",
|
|
700
|
+
"start",
|
|
701
|
+
"\"\"",
|
|
702
|
+
path
|
|
703
|
+
]
|
|
704
|
+
};
|
|
705
|
+
default: return {
|
|
706
|
+
command: "xdg-open",
|
|
707
|
+
args: [path]
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** Spawn the OS file manager to reveal `path`. Resolves once the launcher
|
|
712
|
+
* process has started; rejects only if the launcher itself fails to
|
|
713
|
+
* spawn (missing binary etc.) — not if it later fails to find the path,
|
|
714
|
+
* since the launcher exits asynchronously after we've detached. */
|
|
715
|
+
async function revealInOs(path) {
|
|
716
|
+
const { command, args } = revealCommandFor(path);
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
let settled = false;
|
|
719
|
+
const child = spawn(command, args, {
|
|
720
|
+
detached: true,
|
|
721
|
+
stdio: "ignore"
|
|
722
|
+
});
|
|
723
|
+
child.once("error", (err) => {
|
|
724
|
+
if (settled) return;
|
|
725
|
+
settled = true;
|
|
726
|
+
reject(err);
|
|
727
|
+
});
|
|
728
|
+
child.once("spawn", () => {
|
|
729
|
+
if (settled) return;
|
|
730
|
+
settled = true;
|
|
731
|
+
child.unref();
|
|
732
|
+
resolve();
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/routes/sessions.ts
|
|
738
|
+
function sessionsRoute() {
|
|
739
|
+
const r = new Hono();
|
|
740
|
+
r.get("/", async (c) => {
|
|
741
|
+
const sessions = await listSessions(resolveByfHome());
|
|
742
|
+
return c.json({ sessions });
|
|
743
|
+
});
|
|
744
|
+
r.delete("/:id", async (c) => {
|
|
745
|
+
const id = c.req.param("id");
|
|
746
|
+
const target = (await listSessions(resolveByfHome())).find((s) => s.sessionId === id);
|
|
747
|
+
if (!target) return c.json({
|
|
748
|
+
error: "session not found",
|
|
749
|
+
code: "NOT_FOUND"
|
|
750
|
+
}, 404);
|
|
751
|
+
await rm(target.sessionDir, {
|
|
752
|
+
recursive: true,
|
|
753
|
+
force: true
|
|
754
|
+
});
|
|
755
|
+
return c.json({
|
|
756
|
+
sessionId: id,
|
|
757
|
+
deleted: true
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
r.post("/:id/reveal", async (c) => {
|
|
761
|
+
const id = c.req.param("id");
|
|
762
|
+
const detail = await readSessionDetail(resolveByfHome(), id);
|
|
763
|
+
if (!detail) return c.json({
|
|
764
|
+
error: "session not found",
|
|
765
|
+
code: "NOT_FOUND"
|
|
766
|
+
}, 404);
|
|
767
|
+
try {
|
|
768
|
+
await revealInOs(detail.sessionDir);
|
|
769
|
+
return c.json({
|
|
770
|
+
sessionId: id,
|
|
771
|
+
opened: detail.sessionDir
|
|
772
|
+
});
|
|
773
|
+
} catch (error) {
|
|
774
|
+
return c.json({
|
|
775
|
+
error: `failed to open: ${error.message}`,
|
|
776
|
+
code: "READ_ERROR"
|
|
777
|
+
}, 500);
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
return r;
|
|
781
|
+
}
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region src/lib/agent-tree.ts
|
|
784
|
+
/**
|
|
785
|
+
* Build a parent/child tree from the flat agent inventory found on
|
|
786
|
+
* `state.json.agents`. Roots are agents with no `parentAgentId`, plus any
|
|
787
|
+
* agent whose `parentAgentId` does not resolve in the inventory (orphans).
|
|
788
|
+
* The returned roots are sorted so that the `main` agent always appears
|
|
789
|
+
* first; remaining roots fall back to a stable lexicographic order.
|
|
790
|
+
*/
|
|
791
|
+
function buildAgentTree(agents) {
|
|
792
|
+
const byId = /* @__PURE__ */ new Map();
|
|
793
|
+
for (const a of agents) byId.set(a.agentId, {
|
|
794
|
+
...a,
|
|
795
|
+
children: []
|
|
796
|
+
});
|
|
797
|
+
const roots = [];
|
|
798
|
+
for (const node of byId.values()) if (node.parentAgentId !== null && byId.has(node.parentAgentId)) byId.get(node.parentAgentId).children.push(node);
|
|
799
|
+
else roots.push(node);
|
|
800
|
+
return roots.toSorted(sortAgents);
|
|
801
|
+
}
|
|
802
|
+
function sortAgents(a, b) {
|
|
803
|
+
if (a.agentId === "main") return -1;
|
|
804
|
+
if (b.agentId === "main") return 1;
|
|
805
|
+
return a.agentId.localeCompare(b.agentId);
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/routes/subagents.ts
|
|
809
|
+
function subagentsRoute() {
|
|
810
|
+
const r = new Hono();
|
|
811
|
+
r.get("/:id/agents", async (c) => {
|
|
812
|
+
const id = c.req.param("id");
|
|
813
|
+
const detail = await readSessionDetail(resolveByfHome(), id);
|
|
814
|
+
if (!detail) return c.json({
|
|
815
|
+
error: "session not found",
|
|
816
|
+
code: "NOT_FOUND"
|
|
817
|
+
}, 404);
|
|
818
|
+
return c.json({
|
|
819
|
+
sessionId: id,
|
|
820
|
+
tree: buildAgentTree(detail.agents)
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
return r;
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/routes/wire.ts
|
|
827
|
+
function wireRoute() {
|
|
828
|
+
const r = new Hono();
|
|
829
|
+
r.get("/:id/wire", async (c) => {
|
|
830
|
+
const id = c.req.param("id");
|
|
831
|
+
const agentId = c.req.query("agent") ?? "main";
|
|
832
|
+
if (!isSafeAgentId(agentId)) return c.json({
|
|
833
|
+
error: "invalid agent id",
|
|
834
|
+
code: "BAD_REQUEST"
|
|
835
|
+
}, 400);
|
|
836
|
+
const detail = await readSessionDetail(resolveByfHome(), id);
|
|
837
|
+
if (!detail) return c.json({
|
|
838
|
+
error: "session not found",
|
|
839
|
+
code: "NOT_FOUND"
|
|
840
|
+
}, 404);
|
|
841
|
+
const agent = detail.agents.find((a) => a.agentId === agentId);
|
|
842
|
+
if (!agent) return c.json({
|
|
843
|
+
error: `agent "${agentId}" not found`,
|
|
844
|
+
code: "NOT_FOUND"
|
|
845
|
+
}, 404);
|
|
846
|
+
if (!agent.wireExists) return c.json({
|
|
847
|
+
error: "wire missing",
|
|
848
|
+
code: "NOT_FOUND"
|
|
849
|
+
}, 404);
|
|
850
|
+
try {
|
|
851
|
+
const result = await readAgentWire(join(detail.sessionDir, "agents", agentId, "wire.jsonl"));
|
|
852
|
+
return c.json({
|
|
853
|
+
sessionId: id,
|
|
854
|
+
agentId,
|
|
855
|
+
protocolVersion: result.metadata.protocolVersion,
|
|
856
|
+
metadata: result.metadata,
|
|
857
|
+
records: result.records,
|
|
858
|
+
warnings: result.warnings
|
|
859
|
+
});
|
|
860
|
+
} catch (error) {
|
|
861
|
+
const msg = error.message;
|
|
862
|
+
if (msg.toLowerCase().includes("unsupported protocol")) return c.json({
|
|
863
|
+
error: msg,
|
|
864
|
+
code: "UNSUPPORTED_PROTOCOL"
|
|
865
|
+
}, 400);
|
|
866
|
+
return c.json({
|
|
867
|
+
error: msg,
|
|
868
|
+
code: "READ_ERROR"
|
|
869
|
+
}, 500);
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
return r;
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/app.ts
|
|
876
|
+
/** Resolve the SPA bundle directory next to the compiled server.mjs, if it
|
|
877
|
+
* exists. Returns `null` in dev mode where the web bundle lives elsewhere. */
|
|
878
|
+
async function resolvePublicDir() {
|
|
879
|
+
try {
|
|
880
|
+
const here = import.meta.dirname;
|
|
881
|
+
const candidate = resolve(here, "public");
|
|
882
|
+
if ((await stat(candidate)).isDirectory()) return candidate;
|
|
883
|
+
} catch {}
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
const STATIC_EXT_MIME = {
|
|
887
|
+
".html": "text/html; charset=utf-8",
|
|
888
|
+
".js": "application/javascript; charset=utf-8",
|
|
889
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
890
|
+
".css": "text/css; charset=utf-8",
|
|
891
|
+
".json": "application/json; charset=utf-8",
|
|
892
|
+
".svg": "image/svg+xml",
|
|
893
|
+
".png": "image/png",
|
|
894
|
+
".jpg": "image/jpeg",
|
|
895
|
+
".jpeg": "image/jpeg",
|
|
896
|
+
".gif": "image/gif",
|
|
897
|
+
".ico": "image/x-icon",
|
|
898
|
+
".woff": "font/woff",
|
|
899
|
+
".woff2": "font/woff2",
|
|
900
|
+
".ttf": "font/ttf",
|
|
901
|
+
".map": "application/json; charset=utf-8"
|
|
902
|
+
};
|
|
903
|
+
function mimeFor(path) {
|
|
904
|
+
const i = path.lastIndexOf(".");
|
|
905
|
+
if (i < 0) return "application/octet-stream";
|
|
906
|
+
return STATIC_EXT_MIME[path.slice(i).toLowerCase()] ?? "application/octet-stream";
|
|
907
|
+
}
|
|
908
|
+
function bearerToken(value) {
|
|
909
|
+
if (value === void 0) return null;
|
|
910
|
+
return /^Bearer\s+(.+)$/i.exec(value)?.[1]?.trim() ?? null;
|
|
911
|
+
}
|
|
912
|
+
function tokenMatches(actual, expected) {
|
|
913
|
+
const actualBytes = Buffer.from(actual);
|
|
914
|
+
const expectedBytes = Buffer.from(expected);
|
|
915
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
916
|
+
}
|
|
917
|
+
/** Build a Hono app mounting /api/* routes, plus SPA static fallback. */
|
|
918
|
+
async function createApp(options = {}) {
|
|
919
|
+
const app = new Hono();
|
|
920
|
+
const api = new Hono();
|
|
921
|
+
const authToken = options.authToken;
|
|
922
|
+
if (authToken !== void 0 && authToken.length > 0) api.use("*", async (c, next) => {
|
|
923
|
+
const token = bearerToken(c.req.header("authorization"));
|
|
924
|
+
if (token !== null && tokenMatches(token, authToken)) {
|
|
925
|
+
await next();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
c.header("www-authenticate", "Bearer realm=\"byf-vis\"");
|
|
929
|
+
return c.json({
|
|
930
|
+
error: "unauthorized",
|
|
931
|
+
code: "UNAUTHORIZED"
|
|
932
|
+
}, 401);
|
|
933
|
+
});
|
|
934
|
+
api.route("/sessions", sessionsRoute());
|
|
935
|
+
api.route("/sessions", sessionDetailRoute());
|
|
936
|
+
api.route("/sessions", wireRoute());
|
|
937
|
+
api.route("/sessions", subagentsRoute());
|
|
938
|
+
api.route("/sessions", contextRoute());
|
|
939
|
+
app.route("/api", api);
|
|
940
|
+
const publicDir = options.publicDir ?? await resolvePublicDir();
|
|
941
|
+
if (publicDir !== null) app.get("*", async (c) => {
|
|
942
|
+
const url = new URL(c.req.url);
|
|
943
|
+
let pathname = decodeURIComponent(url.pathname);
|
|
944
|
+
if (pathname.startsWith("/api")) return c.json({
|
|
945
|
+
error: `api route not found: ${pathname}`,
|
|
946
|
+
code: "NOT_FOUND"
|
|
947
|
+
}, 404);
|
|
948
|
+
if (pathname === "/" || pathname === "") pathname = "/index.html";
|
|
949
|
+
const resolved = resolve(publicDir, `.${pathname}`);
|
|
950
|
+
if (!resolved.startsWith(publicDir)) return c.text("forbidden", 403);
|
|
951
|
+
try {
|
|
952
|
+
if ((await stat(resolved)).isFile()) {
|
|
953
|
+
const buf = await readFile(resolved);
|
|
954
|
+
const body = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
955
|
+
return new Response(body, { headers: { "content-type": mimeFor(resolved) } });
|
|
956
|
+
}
|
|
957
|
+
} catch {}
|
|
958
|
+
try {
|
|
959
|
+
const indexHtml = await readFile(join(publicDir, "index.html"));
|
|
960
|
+
const body = new Uint8Array(indexHtml.buffer, indexHtml.byteOffset, indexHtml.byteLength);
|
|
961
|
+
return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
962
|
+
} catch {
|
|
963
|
+
return c.text("not found", 404);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
return app;
|
|
967
|
+
}
|
|
968
|
+
//#endregion
|
|
969
|
+
//#region src/startup-banner.ts
|
|
970
|
+
function formatStartupBanner(options) {
|
|
971
|
+
const authStatus = options.authToken === void 0 ? "auth=disabled" : "auth=required";
|
|
972
|
+
return `[vis-server] listening on http://${hostForUrl$1(options.host)}:${String(options.port)} (${authStatus}, BYF_HOME=${options.byfCodeHome})\n`;
|
|
973
|
+
}
|
|
974
|
+
function hostForUrl$1(host) {
|
|
975
|
+
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
|
|
976
|
+
return host;
|
|
977
|
+
}
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/server.ts
|
|
980
|
+
function hostForUrl(host) {
|
|
981
|
+
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
|
|
982
|
+
return host;
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Start the vis HTTP server programmatically. Resolves once the server is
|
|
986
|
+
* listening. Used by the CLI `byf vis` subcommand (in-process) and by the
|
|
987
|
+
* standalone `index.ts` entry.
|
|
988
|
+
*/
|
|
989
|
+
async function startVisServer(options = {}) {
|
|
990
|
+
const host = options.host ?? resolveHost();
|
|
991
|
+
const port = options.port ?? resolvePort();
|
|
992
|
+
const app = await createApp({
|
|
993
|
+
authToken: options.authToken ?? resolveVisAuthToken(host),
|
|
994
|
+
publicDir: options.publicDir
|
|
995
|
+
});
|
|
996
|
+
return new Promise((resolve, reject) => {
|
|
997
|
+
let settled = false;
|
|
998
|
+
const server = serve({
|
|
999
|
+
fetch: app.fetch,
|
|
1000
|
+
hostname: host,
|
|
1001
|
+
port
|
|
1002
|
+
}, () => {
|
|
1003
|
+
if (settled) return;
|
|
1004
|
+
settled = true;
|
|
1005
|
+
const address = server.address();
|
|
1006
|
+
const actualPort = typeof address === "object" && address !== null ? address.port : port;
|
|
1007
|
+
resolve({
|
|
1008
|
+
host,
|
|
1009
|
+
port: actualPort,
|
|
1010
|
+
url: `http://${hostForUrl(host)}:${actualPort}`,
|
|
1011
|
+
close: () => {
|
|
1012
|
+
server.closeAllConnections();
|
|
1013
|
+
server.close();
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
server.on("error", (error) => {
|
|
1018
|
+
if (settled) return;
|
|
1019
|
+
settled = true;
|
|
1020
|
+
reject(error);
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Resolve the BYF_HOME the server reads session records from. Exposed for the
|
|
1026
|
+
* standalone entry's startup banner. CLI consumers rely on the same env var.
|
|
1027
|
+
*/
|
|
1028
|
+
function resolveVisByfHome() {
|
|
1029
|
+
return resolveByfHome();
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Format the startup banner text. Exposed so the CLI can reuse the exact same
|
|
1033
|
+
* wording without depending on startup-banner internals.
|
|
1034
|
+
*/
|
|
1035
|
+
function formatVisStartupBanner(input) {
|
|
1036
|
+
return formatStartupBanner({
|
|
1037
|
+
authToken: input.authToken,
|
|
1038
|
+
host: input.host,
|
|
1039
|
+
byfCodeHome: resolveByfHome(),
|
|
1040
|
+
port: input.port
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
//#endregion
|
|
1044
|
+
export { resolvePort as a, resolveHost as i, resolveVisByfHome as n, resolveVisAuthToken as o, startVisServer as r, formatVisStartupBanner as t };
|