mainflow 0.6.0
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 +21 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +324 -0
- package/dist/dist-BktkzRuK.js +1615 -0
- package/dist/dist-D2NK-QQP.js +511 -0
- package/dist/facts.d.ts +518 -0
- package/dist/facts.js +516 -0
- package/dist/hooks.d.ts +41 -0
- package/dist/hooks.js +171 -0
- package/dist/host.d.ts +24 -0
- package/dist/host.js +89 -0
- package/dist/launch-BR70uSMc.js +584 -0
- package/dist/launch.d.ts +44 -0
- package/dist/launch.js +2 -0
- package/dist/web/assets/index-BshtZ25f.js +78 -0
- package/dist/web/assets/index-qO7SaeFe.css +1 -0
- package/dist/web/assets/index.browser-C_4gp6WJ.js +169 -0
- package/dist/web/index.html +13 -0
- package/package.json +48 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { B as refIndex, G as DATA_DIR, I as setIssueArchived, P as setFindingArchived, R as loadHub, U as ConflictError, W as WriteError, b as setSessionArchived, d as setChecklistItem, f as setCriterion, h as updateTask, p as setMainlineStatus, u as setArchived, v as loadSessions, w as transcriptPath, y as readSession, z as linksInto } from "./dist-BktkzRuK.js";
|
|
2
|
+
import { DEFAULT_UI_PORT, syncHost } from "./host.js";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, watch, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { createServer } from "node:net";
|
|
9
|
+
import { serve } from "@hono/node-server";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { streamSSE } from "hono/streaming";
|
|
13
|
+
//#region ../core/dist/trace.js
|
|
14
|
+
const HOOK_BLOCK = /^(\w+:\w+) hook error:\s*(.*)$/s;
|
|
15
|
+
const SCRIPT_IN_COMMAND = /(\S+\.(?:mjs|cjs|js|sh|py|ts))\b/;
|
|
16
|
+
/**
|
|
17
|
+
* Rules live under a rules/ directory. Everything else the instructions record loads at session start is
|
|
18
|
+
* an instruction, including files CLAUDE.md pulls in with @imports. A nested memory outside rules/ is an
|
|
19
|
+
* instruction only when it is a CLAUDE.md or AGENTS.md of a subdirectory.
|
|
20
|
+
*/
|
|
21
|
+
const isRule = (path) => /\/rules\//.test(path);
|
|
22
|
+
const startupKind = (path) => isRule(path) ? "rule" : "instruction";
|
|
23
|
+
const nestedKind = (path) => isRule(path) ? "rule" : /(^|\/)(CLAUDE|AGENTS)(\.local)?\.md$/.test(path) ? "instruction" : "file";
|
|
24
|
+
/** Where an installed skill or agent definition sits: the project's .claude first, then the user's. */
|
|
25
|
+
function installed(kind, name, cwd, home) {
|
|
26
|
+
const leaf = kind === "skills" ? join(name, "SKILL.md") : `${name}.md`;
|
|
27
|
+
return [...cwd ? [join(cwd, ".claude", kind, leaf)] : [], join(home, ".claude", kind, leaf)].find((p) => existsSync(p));
|
|
28
|
+
}
|
|
29
|
+
function traceTranscript(file, options) {
|
|
30
|
+
const entries = [];
|
|
31
|
+
let model;
|
|
32
|
+
if (!existsSync(file)) return { entries };
|
|
33
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
let record;
|
|
36
|
+
try {
|
|
37
|
+
record = JSON.parse(line);
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const at = record.timestamp;
|
|
42
|
+
if (!at) continue;
|
|
43
|
+
const a = record.attachment;
|
|
44
|
+
if (a?.type === "model" && a.identity?.modelId) model = a.identity.modelId;
|
|
45
|
+
if (a?.type === "nested_memory" && a.path) entries.push({
|
|
46
|
+
at,
|
|
47
|
+
kind: nestedKind(a.path),
|
|
48
|
+
name: basename(a.path),
|
|
49
|
+
path: a.path
|
|
50
|
+
});
|
|
51
|
+
if (a?.type === "instructions") {
|
|
52
|
+
for (const f of a.files ?? []) if (f.path) entries.push({
|
|
53
|
+
at,
|
|
54
|
+
kind: startupKind(f.path),
|
|
55
|
+
name: basename(f.path),
|
|
56
|
+
path: f.path
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if ((a?.type === "hook_success" || a?.type === "hook_additional_context") && a.hookName) {
|
|
60
|
+
const script = a.command?.match(SCRIPT_IN_COMMAND)?.[1]?.replace("$HOME", options.home);
|
|
61
|
+
entries.push({
|
|
62
|
+
at,
|
|
63
|
+
kind: "hook",
|
|
64
|
+
name: a.hookName,
|
|
65
|
+
...script ? { path: script } : {},
|
|
66
|
+
...a.exitCode !== void 0 ? { detail: `exit ${a.exitCode}` } : {}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const content = Array.isArray(record.message?.content) ? record.message.content : [];
|
|
70
|
+
if (record.type === "assistant" && record.message?.model && record.message.model !== "<synthetic>") model = record.message.model;
|
|
71
|
+
for (const block of content) {
|
|
72
|
+
if (block.type === "tool_use") {
|
|
73
|
+
const input = block.input ?? {};
|
|
74
|
+
if (block.name === "Skill" && typeof input.skill === "string") {
|
|
75
|
+
const path = installed("skills", input.skill, record.cwd, options.home);
|
|
76
|
+
entries.push({
|
|
77
|
+
at,
|
|
78
|
+
kind: "skill",
|
|
79
|
+
name: input.skill,
|
|
80
|
+
...path ? { path } : {}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if ((block.name === "Agent" || block.name === "Task") && typeof input.subagent_type === "string") {
|
|
84
|
+
const path = installed("agents", input.subagent_type, record.cwd, options.home);
|
|
85
|
+
entries.push({
|
|
86
|
+
at,
|
|
87
|
+
kind: "agent",
|
|
88
|
+
name: input.subagent_type,
|
|
89
|
+
...path ? { path } : {}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (block.name === "Read" && typeof input.file_path === "string") entries.push({
|
|
93
|
+
at,
|
|
94
|
+
kind: "file",
|
|
95
|
+
name: basename(input.file_path),
|
|
96
|
+
path: input.file_path
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (block.type === "tool_result" && block.is_error) {
|
|
100
|
+
const blocked = (typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? "")).match(HOOK_BLOCK);
|
|
101
|
+
if (blocked) entries.push({
|
|
102
|
+
at,
|
|
103
|
+
kind: "hook",
|
|
104
|
+
name: blocked[1],
|
|
105
|
+
detail: `blocked: ${blocked[2].slice(0, 120)}`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
...model ? { model } : {},
|
|
112
|
+
entries
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Titles are appended again as the conversation goes on, so the end of the file almost always holds the latest. */
|
|
116
|
+
const TITLE_TAIL_BYTES = 262144;
|
|
117
|
+
function readTail(file, bytes) {
|
|
118
|
+
const size = statSync(file).size;
|
|
119
|
+
if (size <= bytes) return {
|
|
120
|
+
text: readFileSync(file, "utf8"),
|
|
121
|
+
whole: true
|
|
122
|
+
};
|
|
123
|
+
const fd = openSync(file, "r");
|
|
124
|
+
try {
|
|
125
|
+
const buffer = Buffer.alloc(bytes);
|
|
126
|
+
readSync(fd, buffer, 0, bytes, size - bytes);
|
|
127
|
+
return {
|
|
128
|
+
text: buffer.subarray(buffer.indexOf(10) + 1).toString("utf8"),
|
|
129
|
+
whole: false
|
|
130
|
+
};
|
|
131
|
+
} finally {
|
|
132
|
+
closeSync(fd);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function latestTitle(text) {
|
|
136
|
+
let custom;
|
|
137
|
+
let agent;
|
|
138
|
+
for (const line of text.split("\n")) {
|
|
139
|
+
if (!line.includes("\"custom-title\"") && !line.includes("\"agent-name\"")) continue;
|
|
140
|
+
let record;
|
|
141
|
+
try {
|
|
142
|
+
record = JSON.parse(line);
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (record.type === "custom-title" && record.customTitle?.trim()) custom = record.customTitle.trim();
|
|
147
|
+
if (record.type === "agent-name" && record.agentName?.trim()) agent = record.agentName.trim();
|
|
148
|
+
}
|
|
149
|
+
return custom ?? agent;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The name the conversation goes by: the latest title the user gave it, else the latest name the platform
|
|
153
|
+
* generated. Only the end of the file is read unless it holds no title.
|
|
154
|
+
*/
|
|
155
|
+
function transcriptTitle(file) {
|
|
156
|
+
if (!existsSync(file)) return void 0;
|
|
157
|
+
const tail = readTail(file, TITLE_TAIL_BYTES);
|
|
158
|
+
return latestTitle(tail.text) ?? (tail.whole ? void 0 : latestTitle(readFileSync(file, "utf8")));
|
|
159
|
+
}
|
|
160
|
+
/** Action stamps are local time to the minute; the window closes at the end of the finishing minute. */
|
|
161
|
+
const localMinute = (stamp) => {
|
|
162
|
+
const [date = "", time = ""] = stamp.split("T");
|
|
163
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
164
|
+
const [hh, mm] = time.split(":").map(Number);
|
|
165
|
+
return new Date(y, m - 1, d, hh, mm).getTime();
|
|
166
|
+
};
|
|
167
|
+
function summarize$1(entries) {
|
|
168
|
+
const uses = /* @__PURE__ */ new Map();
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
const key = `${e.kind}\u0000${e.path ?? e.name}`;
|
|
171
|
+
const use = uses.get(key);
|
|
172
|
+
if (use) use.count += 1;
|
|
173
|
+
else uses.set(key, {
|
|
174
|
+
kind: e.kind,
|
|
175
|
+
name: e.name,
|
|
176
|
+
...e.path ? { path: e.path } : {},
|
|
177
|
+
count: 1,
|
|
178
|
+
first: e.at,
|
|
179
|
+
...e.detail ? { detail: e.detail } : {}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return [...uses.values()];
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Group trace entries by the action that was doing when they happened. Actions without a start stamp
|
|
186
|
+
* get nothing; entries outside every window are returned as unattributed.
|
|
187
|
+
*/
|
|
188
|
+
function attributeTrace(trace, actions, now = Date.now()) {
|
|
189
|
+
const windows = actions.filter((a) => a.startedAt).map((a) => ({
|
|
190
|
+
id: a.id,
|
|
191
|
+
from: localMinute(a.startedAt),
|
|
192
|
+
to: a.doneAt ? localMinute(a.doneAt) + 6e4 : now
|
|
193
|
+
}));
|
|
194
|
+
const grouped = {};
|
|
195
|
+
const rest = [];
|
|
196
|
+
for (const entry of trace.entries) {
|
|
197
|
+
const t = Date.parse(entry.at);
|
|
198
|
+
const owner = windows.filter((w) => t >= w.from && t < w.to).sort((x, y) => y.from - x.from)[0];
|
|
199
|
+
if (owner) (grouped[owner.id] ??= []).push(entry);
|
|
200
|
+
else rest.push(entry);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
byAction: Object.fromEntries(Object.entries(grouped).map(([id, list]) => [id, summarize$1(list)])),
|
|
204
|
+
unattributed: summarize$1(rest)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region ../core/dist/progress.js
|
|
209
|
+
function listProgress(items) {
|
|
210
|
+
const counted = items.filter((item) => !item.dropped);
|
|
211
|
+
return {
|
|
212
|
+
done: counted.filter((item) => item.done).length,
|
|
213
|
+
total: counted.length
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/server.ts
|
|
218
|
+
/** Every reference with a hub prefix in the documents of `hub`: links, origins and replaced items. */
|
|
219
|
+
function prefixedRefs(hub) {
|
|
220
|
+
const refs = [];
|
|
221
|
+
const doc = (d) => refs.push(d.origin, ...(d.links ?? []).map((l) => l.to));
|
|
222
|
+
for (const m of hub.mainlines) {
|
|
223
|
+
doc(m);
|
|
224
|
+
refs.push(...m.criteria.map((c) => c.supersededBy));
|
|
225
|
+
for (const d of m.decisions) doc(d);
|
|
226
|
+
for (const t of m.tasks) {
|
|
227
|
+
doc(t);
|
|
228
|
+
refs.push(...t.checklist.map((c) => c.supersededBy));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const d of [...hub.issues, ...hub.findings]) doc(d);
|
|
232
|
+
return [...new Set(refs.filter((r) => !!r && /^[a-z0-9-]+:/.test(r)))];
|
|
233
|
+
}
|
|
234
|
+
function relationsView(hub) {
|
|
235
|
+
const own = refIndex(hub);
|
|
236
|
+
const indexes = new Map(hub.remotes.flatMap((r) => r.hub ? [[r.name, refIndex(r.hub)]] : []));
|
|
237
|
+
const targets = {};
|
|
238
|
+
for (const ref of prefixedRefs(hub)) {
|
|
239
|
+
const colon = ref.indexOf(":");
|
|
240
|
+
const name = ref.slice(0, colon);
|
|
241
|
+
const found = name === hub.config.hub ? own.get(ref.slice(colon + 1)) : indexes.get(name)?.get(ref.slice(colon + 1));
|
|
242
|
+
if (found) targets[ref] = found;
|
|
243
|
+
}
|
|
244
|
+
const backlinks = {};
|
|
245
|
+
const add = (to, link) => (backlinks[to] ??= []).push(link);
|
|
246
|
+
for (const { to, link } of linksInto(hub, void 0)) add(to, {
|
|
247
|
+
...link,
|
|
248
|
+
...pick(own.get(link.from))
|
|
249
|
+
});
|
|
250
|
+
for (const remote of hub.remotes) {
|
|
251
|
+
if (!remote.hub) continue;
|
|
252
|
+
const index = indexes.get(remote.name);
|
|
253
|
+
for (const { to, link } of linksInto(remote.hub, hub.config.hub)) add(to, {
|
|
254
|
+
...link,
|
|
255
|
+
from: `${remote.name}:${link.from}`,
|
|
256
|
+
...pick(index?.get(link.from))
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
hubs: hub.remotes.map((r) => ({
|
|
261
|
+
name: r.name,
|
|
262
|
+
path: r.path,
|
|
263
|
+
available: !!r.hub,
|
|
264
|
+
...r.unavailable ? { reason: r.unavailable } : {}
|
|
265
|
+
})),
|
|
266
|
+
targets,
|
|
267
|
+
backlinks
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const pick = (t) => t ? {
|
|
271
|
+
title: t.title,
|
|
272
|
+
status: t.status
|
|
273
|
+
} : {};
|
|
274
|
+
const CONTENT_TYPES = {
|
|
275
|
+
".html": "text/html; charset=utf-8",
|
|
276
|
+
".md": "text/markdown; charset=utf-8",
|
|
277
|
+
".js": "text/javascript; charset=utf-8",
|
|
278
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
279
|
+
".vue": "text/plain; charset=utf-8",
|
|
280
|
+
".css": "text/css; charset=utf-8",
|
|
281
|
+
".json": "application/json; charset=utf-8",
|
|
282
|
+
".svg": "image/svg+xml",
|
|
283
|
+
".png": "image/png",
|
|
284
|
+
".jpg": "image/jpeg",
|
|
285
|
+
".txt": "text/plain; charset=utf-8"
|
|
286
|
+
};
|
|
287
|
+
function summarize(m) {
|
|
288
|
+
return {
|
|
289
|
+
id: m.id,
|
|
290
|
+
title: m.title,
|
|
291
|
+
status: m.status,
|
|
292
|
+
created: m.created,
|
|
293
|
+
archivedAt: m.archivedAt ?? null,
|
|
294
|
+
category: m.category ?? null,
|
|
295
|
+
priority: m.priority ?? null,
|
|
296
|
+
labels: m.labels ?? [],
|
|
297
|
+
due: m.due ?? null,
|
|
298
|
+
updated: [
|
|
299
|
+
m,
|
|
300
|
+
...m.tasks,
|
|
301
|
+
...m.decisions
|
|
302
|
+
].map((d) => d.updated).reduce((a, b) => b > a ? b : a),
|
|
303
|
+
tasks: {
|
|
304
|
+
total: m.tasks.length,
|
|
305
|
+
done: m.tasks.filter((t) => t.status === "done").length
|
|
306
|
+
},
|
|
307
|
+
items: listProgress(m.tasks.flatMap((t) => t.checklist)),
|
|
308
|
+
criteria: listProgress(m.criteria),
|
|
309
|
+
decisions: m.decisions.length
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/** HTTP API over one hub. The CLI `ui` command and the web dev server both mount it. */
|
|
313
|
+
function createApp(root) {
|
|
314
|
+
const app = new Hono();
|
|
315
|
+
const mainlinesRoot = join(root, DATA_DIR, "mainlines");
|
|
316
|
+
app.onError((error, c) => {
|
|
317
|
+
const message = error.message.replaceAll(`${root}${sep}`, "");
|
|
318
|
+
if (error instanceof ConflictError) return c.json({ error: message }, 409);
|
|
319
|
+
if (error instanceof WriteError) return c.json({ error: message }, 400);
|
|
320
|
+
throw error;
|
|
321
|
+
});
|
|
322
|
+
app.get("/api/hub", (c) => {
|
|
323
|
+
const hub = loadHub(root);
|
|
324
|
+
const body = {
|
|
325
|
+
hub: hub.config.hub,
|
|
326
|
+
repos: hub.config.repos,
|
|
327
|
+
mainlines: hub.mainlines.map(summarize),
|
|
328
|
+
openIssues: hub.issues.filter((i) => i.status !== "closed").length,
|
|
329
|
+
validFindings: hub.findings.filter((f) => f.status === "valid").length,
|
|
330
|
+
problems: hub.problems
|
|
331
|
+
};
|
|
332
|
+
return c.json(body);
|
|
333
|
+
});
|
|
334
|
+
app.get("/api/relations", (c) => c.json(relationsView(loadHub(root))));
|
|
335
|
+
const local = () => loadHub(root, { remotes: false });
|
|
336
|
+
app.get("/api/issues", (c) => c.json(local().issues));
|
|
337
|
+
app.get("/api/findings", (c) => c.json(local().findings));
|
|
338
|
+
app.get("/api/ops", (c) => c.json(local().ops));
|
|
339
|
+
app.post("/api/reveal", async (c) => {
|
|
340
|
+
const { path } = await c.req.json();
|
|
341
|
+
const known = loadSessions(root).sessions.some((s) => s.transcript === path);
|
|
342
|
+
const file = transcriptPath(root, path);
|
|
343
|
+
if (!known || !existsSync(file)) return c.json({ error: "only a transcript recorded on a session can be revealed" }, 400);
|
|
344
|
+
const opened = spawnSync("open", ["-R", file]);
|
|
345
|
+
return opened.status === 0 ? c.json({ revealed: file }) : c.json({ error: `open -R failed: ${opened.stderr}` }, 500);
|
|
346
|
+
});
|
|
347
|
+
app.get("/api/mainlines/:m", (c) => {
|
|
348
|
+
const mainline = local().mainlines.find((m) => m.id === c.req.param("m"));
|
|
349
|
+
return mainline ? c.json(mainline) : c.json({ error: `mainline ${c.req.param("m")} not found` }, 404);
|
|
350
|
+
});
|
|
351
|
+
const titles = /* @__PURE__ */ new Map();
|
|
352
|
+
const titleOf = (recorded) => {
|
|
353
|
+
const transcript = recorded && transcriptPath(root, recorded);
|
|
354
|
+
if (!transcript || !existsSync(transcript)) return void 0;
|
|
355
|
+
const { size, mtimeMs } = statSync(transcript);
|
|
356
|
+
const stamp = `${size}:${mtimeMs}`;
|
|
357
|
+
const cached = titles.get(transcript);
|
|
358
|
+
if (cached?.stamp === stamp) return cached.title;
|
|
359
|
+
const title = transcriptTitle(transcript);
|
|
360
|
+
titles.set(transcript, {
|
|
361
|
+
stamp,
|
|
362
|
+
title
|
|
363
|
+
});
|
|
364
|
+
return title;
|
|
365
|
+
};
|
|
366
|
+
app.get("/api/sessions", (c) => {
|
|
367
|
+
const { sessions, problems } = loadSessions(root);
|
|
368
|
+
const views = sessions.map((s) => {
|
|
369
|
+
const title = titleOf(s.transcript);
|
|
370
|
+
return title ? {
|
|
371
|
+
...s,
|
|
372
|
+
title
|
|
373
|
+
} : s;
|
|
374
|
+
});
|
|
375
|
+
return c.json({
|
|
376
|
+
sessions: views,
|
|
377
|
+
problems
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
app.post("/api/sessions/:id/archive", async (c) => {
|
|
381
|
+
const { archived } = await c.req.json();
|
|
382
|
+
return c.json(setSessionArchived(root, c.req.param("id"), archived));
|
|
383
|
+
});
|
|
384
|
+
app.post("/api/issues/:id/archive", async (c) => {
|
|
385
|
+
const { archived, version } = await c.req.json();
|
|
386
|
+
return c.json(setIssueArchived(root, c.req.param("id"), archived, version));
|
|
387
|
+
});
|
|
388
|
+
app.post("/api/findings/:id/archive", async (c) => {
|
|
389
|
+
const { archived, version } = await c.req.json();
|
|
390
|
+
return c.json(setFindingArchived(root, c.req.param("id"), archived, version));
|
|
391
|
+
});
|
|
392
|
+
app.get("/api/sessions/:id/trace", (c) => {
|
|
393
|
+
const session = readSession(root, c.req.param("id"));
|
|
394
|
+
if (!session) return c.json({ error: `session ${c.req.param("id")} not found` }, 404);
|
|
395
|
+
const transcript = session.transcript && transcriptPath(root, session.transcript);
|
|
396
|
+
if (!transcript || !existsSync(transcript)) return c.json({
|
|
397
|
+
available: false,
|
|
398
|
+
byAction: {},
|
|
399
|
+
unattributed: []
|
|
400
|
+
});
|
|
401
|
+
const trace = traceTranscript(transcript, { home: homedir() });
|
|
402
|
+
return c.json({
|
|
403
|
+
available: true,
|
|
404
|
+
model: trace.model ?? session.model,
|
|
405
|
+
...attributeTrace(trace, session.actions)
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
app.get("/api/files/*", (c) => {
|
|
409
|
+
const path = normalize(decodeURIComponent(c.req.path.slice(11)));
|
|
410
|
+
const file = join(root, path);
|
|
411
|
+
if (!file.startsWith(mainlinesRoot + sep) || !existsSync(file) || !statSync(file).isFile()) return c.json({ error: `file ${path} not found under ${relative(root, mainlinesRoot)}` }, 404);
|
|
412
|
+
return c.body(readFileSync(file), 200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" });
|
|
413
|
+
});
|
|
414
|
+
app.post("/api/mainlines/:m/archive", async (c) => {
|
|
415
|
+
const { archived, version } = await c.req.json();
|
|
416
|
+
return c.json(setArchived(root, c.req.param("m"), archived, version));
|
|
417
|
+
});
|
|
418
|
+
app.post("/api/mainlines/:m/status", async (c) => {
|
|
419
|
+
const { status, version } = await c.req.json();
|
|
420
|
+
return c.json(setMainlineStatus(root, c.req.param("m"), status, version));
|
|
421
|
+
});
|
|
422
|
+
app.post("/api/tasks/:m/:t/status", async (c) => {
|
|
423
|
+
const { status, version } = await c.req.json();
|
|
424
|
+
return c.json(updateTask(root, `${c.req.param("m")}/${c.req.param("t")}`, { status }, version));
|
|
425
|
+
});
|
|
426
|
+
app.post("/api/check", async (c) => {
|
|
427
|
+
const { ref, done, evidence, version } = await c.req.json();
|
|
428
|
+
const result = ref.includes("/") ? setChecklistItem(root, ref, done, evidence, version) : setCriterion(root, ref, done, evidence, version);
|
|
429
|
+
return c.json(result);
|
|
430
|
+
});
|
|
431
|
+
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
432
|
+
const watcher = watch(join(root, DATA_DIR), { recursive: true }, (_event, name) => {
|
|
433
|
+
if (name && !String(name).endsWith("write.lock") && !String(name).endsWith(".tmp")) stream.writeSSE({
|
|
434
|
+
event: "change",
|
|
435
|
+
data: String(name)
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
stream.onAbort(() => watcher.close());
|
|
439
|
+
while (!stream.aborted) await stream.sleep(3e4);
|
|
440
|
+
}));
|
|
441
|
+
return app;
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/ui.ts
|
|
445
|
+
/**
|
|
446
|
+
* Where the built web client lives: `web/` beside the bundled CLI in the published package, or the
|
|
447
|
+
* workspace's web package when running from source before a bundle exists.
|
|
448
|
+
*/
|
|
449
|
+
function webDir() {
|
|
450
|
+
const bundled = join(dirname(fileURLToPath(import.meta.url)), "web");
|
|
451
|
+
if (existsSync(join(bundled, "index.html"))) return bundled;
|
|
452
|
+
return join(dirname(createRequire(import.meta.url).resolve("@carlos-space/mainflow-web/package.json")), "dist");
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Serve the API plus the built web client; unknown paths fall back to index.html for client-side routing.
|
|
456
|
+
* Resolves once the port is listening. A port taken between the free-port probe and here rejects with a
|
|
457
|
+
* WriteError instead of Node's unhandled 'error' event and stack trace.
|
|
458
|
+
*/
|
|
459
|
+
function serveUi(root, port) {
|
|
460
|
+
const app = createApp(root);
|
|
461
|
+
const dist = webDir();
|
|
462
|
+
app.get("*", (c) => {
|
|
463
|
+
let file = join(dist, normalize(decodeURIComponent(c.req.path)));
|
|
464
|
+
if (!file.startsWith(dist + sep) || !existsSync(file) || !statSync(file).isFile()) file = join(dist, "index.html");
|
|
465
|
+
return c.body(readFileSync(file), 200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" });
|
|
466
|
+
});
|
|
467
|
+
return new Promise((done, fail) => {
|
|
468
|
+
const server = serve({
|
|
469
|
+
fetch: app.fetch,
|
|
470
|
+
port
|
|
471
|
+
}, () => done(server));
|
|
472
|
+
server.once("error", (error) => fail(error.code === "EADDRINUSE" ? new WriteError(`port ${port} is already in use; run mainflow ui again to pick another`) : error));
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/launch.ts
|
|
477
|
+
/** How many ports above DEFAULT_UI_PORT are tried before giving up. */
|
|
478
|
+
const PORT_SPAN = 100;
|
|
479
|
+
/** Where the last port used is remembered: machine-specific, so under the unversioned local/ folder. */
|
|
480
|
+
const lastPortFile = (root) => join(root, DATA_DIR, "local", "ui.json");
|
|
481
|
+
function readLastPort(root) {
|
|
482
|
+
try {
|
|
483
|
+
const port = JSON.parse(readFileSync(lastPortFile(root), "utf8")).port;
|
|
484
|
+
return Number.isInteger(port) ? port : void 0;
|
|
485
|
+
} catch {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function writeLastPort(root, port) {
|
|
490
|
+
mkdirSync(join(root, DATA_DIR, "local"), { recursive: true });
|
|
491
|
+
writeFileSync(lastPortFile(root), `${JSON.stringify({ port })}\n`);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
495
|
+
* the hub's configured port, then the last one used, then the default range.
|
|
496
|
+
*/
|
|
497
|
+
function portCandidates(options) {
|
|
498
|
+
if (options.explicit !== void 0) return [options.explicit];
|
|
499
|
+
const range = Array.from({ length: 100 }, (_, i) => DEFAULT_UI_PORT + i);
|
|
500
|
+
return [...new Set([
|
|
501
|
+
options.configured,
|
|
502
|
+
options.last,
|
|
503
|
+
...range
|
|
504
|
+
].filter((p) => p !== void 0))];
|
|
505
|
+
}
|
|
506
|
+
const networkProbe = {
|
|
507
|
+
async hubAt(port) {
|
|
508
|
+
try {
|
|
509
|
+
const res = await fetch(`http://localhost:${port}/api/hub`, { signal: AbortSignal.timeout(500) });
|
|
510
|
+
if (!res.ok) return null;
|
|
511
|
+
const body = await res.json();
|
|
512
|
+
return typeof body.hub === "string" ? body.hub : null;
|
|
513
|
+
} catch {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
isFree: (port) => new Promise((done) => {
|
|
518
|
+
const probe = createServer().once("error", () => done(false));
|
|
519
|
+
probe.listen(port, () => probe.close(() => done(true)));
|
|
520
|
+
})
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
524
|
+
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
525
|
+
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
526
|
+
*/
|
|
527
|
+
async function choosePort(hub, candidates, probe = networkProbe) {
|
|
528
|
+
const owners = await Promise.all(candidates.map((port) => probe.hubAt(port)));
|
|
529
|
+
const running = candidates.find((_, i) => owners[i] === hub);
|
|
530
|
+
if (running !== void 0) return {
|
|
531
|
+
port: running,
|
|
532
|
+
running: true
|
|
533
|
+
};
|
|
534
|
+
for (const [i, port] of candidates.entries()) if (owners[i] === null && await probe.isFree(port)) return {
|
|
535
|
+
port,
|
|
536
|
+
running: false
|
|
537
|
+
};
|
|
538
|
+
const tried = candidates.length === 1 ? `port ${candidates[0]} is` : `ports ${candidates[0]} to ${candidates.at(-1)} are`;
|
|
539
|
+
throw new WriteError(`${tried} taken by other programs; free one, or pass --port <port>`);
|
|
540
|
+
}
|
|
541
|
+
/** Open a URL in the default browser without waiting for it. */
|
|
542
|
+
function openUrl(url) {
|
|
543
|
+
const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
|
|
544
|
+
"cmd",
|
|
545
|
+
"/c",
|
|
546
|
+
"start",
|
|
547
|
+
"",
|
|
548
|
+
url
|
|
549
|
+
] : ["xdg-open", url];
|
|
550
|
+
spawn(command, args, {
|
|
551
|
+
detached: true,
|
|
552
|
+
stdio: "ignore"
|
|
553
|
+
}).on("error", () => console.log(`open ${url} in a browser`)).unref();
|
|
554
|
+
}
|
|
555
|
+
/** Start or reuse this hub's interface: the URL it is reached at, and the server when this call started one. */
|
|
556
|
+
async function launchUi(root, hub, configured, options) {
|
|
557
|
+
const log = options.log ?? console.log;
|
|
558
|
+
let explicit;
|
|
559
|
+
if (options.port !== void 0) {
|
|
560
|
+
explicit = Number(options.port);
|
|
561
|
+
if (!Number.isInteger(explicit) || explicit < 1024 || explicit > 65535) throw new WriteError("--port must be an integer from 1024 to 65535, e.g. --port 4320");
|
|
562
|
+
}
|
|
563
|
+
const choice = await choosePort(hub, portCandidates({
|
|
564
|
+
explicit,
|
|
565
|
+
configured,
|
|
566
|
+
last: readLastPort(root)
|
|
567
|
+
}), options.probe);
|
|
568
|
+
let server;
|
|
569
|
+
if (!choice.running) {
|
|
570
|
+
server = await serveUi(root, choice.port);
|
|
571
|
+
writeLastPort(root, choice.port);
|
|
572
|
+
}
|
|
573
|
+
const host = await syncHost(root, hub, choice.port, options.host);
|
|
574
|
+
if (host.url === null) log(`! host name not available: ${host.reason}`);
|
|
575
|
+
const url = host.url ?? `http://localhost:${choice.port}`;
|
|
576
|
+
log(`${choice.running ? "already running" : "mainflow ui"}: ${url} (localhost:${choice.port})`);
|
|
577
|
+
if (options.open) openUrl(url);
|
|
578
|
+
return {
|
|
579
|
+
url,
|
|
580
|
+
server
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
export { portCandidates as a, networkProbe as i, choosePort as n, readLastPort as o, launchUi as r, listProgress as s, PORT_SPAN as t };
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HostOptions } from "./host.js";
|
|
2
|
+
import { ServerType } from "@hono/node-server";
|
|
3
|
+
//#region src/launch.d.ts
|
|
4
|
+
/** How many ports above DEFAULT_UI_PORT are tried before giving up. */
|
|
5
|
+
export declare const PORT_SPAN = 100;
|
|
6
|
+
export declare function readLastPort(root: string): number | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Ports to try, in order. An explicit --port is the only candidate: the user asked for that port. Otherwise
|
|
9
|
+
* the hub's configured port, then the last one used, then the default range.
|
|
10
|
+
*/
|
|
11
|
+
export declare function portCandidates(options: {
|
|
12
|
+
explicit?: number;
|
|
13
|
+
configured?: number;
|
|
14
|
+
last?: number;
|
|
15
|
+
}): number[];
|
|
16
|
+
export interface PortProbe {
|
|
17
|
+
/** The hub name a mainflow interface on this port reports, or null when nothing mainflow answers. */
|
|
18
|
+
hubAt: (port: number) => Promise<string | null>;
|
|
19
|
+
isFree: (port: number) => Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
export declare const networkProbe: PortProbe;
|
|
22
|
+
export type PortChoice = {
|
|
23
|
+
port: number;
|
|
24
|
+
running: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A candidate already serving this hub wins wherever it sits in the order, so a lost last-port record or a
|
|
28
|
+
* changed ui.port never starts a second copy; all candidates are asked at once, bounding the wait by one
|
|
29
|
+
* probe timeout. Otherwise the first free candidate; ports held by another hub or program are skipped.
|
|
30
|
+
*/
|
|
31
|
+
export declare function choosePort(hub: string, candidates: number[], probe?: PortProbe): Promise<PortChoice>;
|
|
32
|
+
export interface LaunchOptions {
|
|
33
|
+
port?: string;
|
|
34
|
+
open: boolean;
|
|
35
|
+
probe?: PortProbe;
|
|
36
|
+
host?: HostOptions;
|
|
37
|
+
log?: (line: string) => void;
|
|
38
|
+
}
|
|
39
|
+
/** Start or reuse this hub's interface: the URL it is reached at, and the server when this call started one. */
|
|
40
|
+
export declare function launchUi(root: string, hub: string, configured: number | undefined, options: LaunchOptions): Promise<{
|
|
41
|
+
url: string;
|
|
42
|
+
server?: ServerType;
|
|
43
|
+
}>;
|
|
44
|
+
//#endregion
|
package/dist/launch.js
ADDED