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
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { D as turnReminder, E as sessionBriefing, G as DATA_DIR, J as worktreesOf, K as currentBranch, L as HubError, R as loadHub, S as setSessionStatus, _ as ensureSession, q as findHubRoot, x as setSessionInfo, y as readSession } from "./dist-BktkzRuK.js";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
//#region src/hooks.ts
|
|
5
|
+
const SUMMARY_LIMIT = 280;
|
|
6
|
+
/** The parent transcript `<dir>/<session>.jsonl` keeps its subagents under `<dir>/<session>/subagents/`. */
|
|
7
|
+
function subagentTranscript(parentTranscript, agentId) {
|
|
8
|
+
return join(parentTranscript.replace(/\.jsonl$/, ""), "subagents", `agent-${agentId}.jsonl`);
|
|
9
|
+
}
|
|
10
|
+
function readMeta(transcript) {
|
|
11
|
+
const file = transcript.replace(/\.jsonl$/, ".meta.json");
|
|
12
|
+
if (!existsSync(file)) return {};
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const brief = (text) => {
|
|
20
|
+
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
|
21
|
+
return flat.length > SUMMARY_LIMIT ? `${flat.slice(0, 279)}…` : flat;
|
|
22
|
+
};
|
|
23
|
+
const shortId = (raw) => raw.replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8);
|
|
24
|
+
const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
|
|
25
|
+
/**
|
|
26
|
+
* One handler per Claude Code event; each returns what to print for the model, or null. SessionStart and
|
|
27
|
+
* UserPromptSubmit take plain text; PostToolUse takes the hook JSON, since its plain stdout never reaches the model.
|
|
28
|
+
*/
|
|
29
|
+
const CLAUDE_CODE_EVENTS = {
|
|
30
|
+
"session-start": (root, { payload, envFile, cli }) => {
|
|
31
|
+
if (!payload.session_id) return null;
|
|
32
|
+
const id = claudeCodeSessionId(payload.session_id);
|
|
33
|
+
const cwd = payload.cwd ?? root;
|
|
34
|
+
ensureSession(root, {
|
|
35
|
+
id,
|
|
36
|
+
agent: "claude-code",
|
|
37
|
+
cwd
|
|
38
|
+
});
|
|
39
|
+
const session = setSessionInfo(root, id, {
|
|
40
|
+
transcript: payload.transcript_path,
|
|
41
|
+
worktree: worktreesOf(cwd)?.current,
|
|
42
|
+
branch: currentBranch(cwd) ?? void 0,
|
|
43
|
+
effort: payload.effort?.level
|
|
44
|
+
});
|
|
45
|
+
if (envFile) appendFileSync(envFile, `export MAINFLOW_SESSION=${id}\n`);
|
|
46
|
+
return sessionBriefing(root, session, {
|
|
47
|
+
cli,
|
|
48
|
+
envNote: envFile ? "已設定環境變數 MAINFLOW_SESSION,指令不必再帶 --session" : `執行指令時帶 --session ${id}`
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
"user-prompt-submit": (root, { payload, cli }) => {
|
|
52
|
+
const session = payload.session_id ? readSession(root, claudeCodeSessionId(payload.session_id)) : null;
|
|
53
|
+
return session ? turnReminder(root, session, { cli }) : null;
|
|
54
|
+
},
|
|
55
|
+
"subagent-start": (root, { payload }) => {
|
|
56
|
+
if (!payload.agent_id) return null;
|
|
57
|
+
const parent = payload.session_id ? claudeCodeSessionId(payload.session_id) : null;
|
|
58
|
+
ensureSession(root, {
|
|
59
|
+
id: claudeCodeSessionId(payload.agent_id),
|
|
60
|
+
agent: payload.agent_type ? `claude-code/${payload.agent_type}` : "claude-code",
|
|
61
|
+
cwd: payload.cwd ?? root,
|
|
62
|
+
delegatedFrom: parent && readSession(root, parent) ? parent : null
|
|
63
|
+
});
|
|
64
|
+
return null;
|
|
65
|
+
},
|
|
66
|
+
"subagent-stop": (root, { payload }) => {
|
|
67
|
+
const id = payload.agent_id ? claudeCodeSessionId(payload.agent_id) : null;
|
|
68
|
+
if (!id || !readSession(root, id)) return null;
|
|
69
|
+
const transcript = payload.transcript_path ? subagentTranscript(payload.transcript_path, payload.agent_id) : void 0;
|
|
70
|
+
const meta = transcript ? readMeta(transcript) : {};
|
|
71
|
+
setSessionInfo(root, id, {
|
|
72
|
+
transcript: transcript && existsSync(transcript) ? transcript : void 0,
|
|
73
|
+
description: meta.description,
|
|
74
|
+
depth: meta.spawnDepth,
|
|
75
|
+
summary: brief(payload.last_assistant_message) || void 0
|
|
76
|
+
});
|
|
77
|
+
setSessionStatus(root, id, "ended");
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
"post-tool-use": checkAfterWrite
|
|
81
|
+
};
|
|
82
|
+
/** How many problems outside the written file the reminder lists before it only gives the count. */
|
|
83
|
+
const OTHER_PROBLEMS_SHOWN = 5;
|
|
84
|
+
const problemLine = (p) => `- ${p.file}${p.field ? ` › ${p.field}` : ""}:${p.message}`;
|
|
85
|
+
/**
|
|
86
|
+
* The hub a written file belongs to: the nearest `.mainflow` above it that holds config.yaml. It is
|
|
87
|
+
* found from the file itself rather than from the session's directory, because a worktree maps the
|
|
88
|
+
* session to the main tree's hub while the file was written to the worktree's own copy.
|
|
89
|
+
*/
|
|
90
|
+
function hubOfFile(file) {
|
|
91
|
+
for (let dir = dirname(file); dirname(dir) !== dir; dir = dirname(dir)) if (basename(dir) === ".mainflow" && existsSync(join(dir, "config.yaml"))) return dirname(dir);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* After a tool writes under `.mainflow/`, run the same checks as `mainflow check` and hand the problems
|
|
96
|
+
* to the model in the same turn. Edit, Write and MultiEdit name the file; Bash only counts when its
|
|
97
|
+
* command mentions `.mainflow`, a text match that misses paths built from variables. A clean hub, or a
|
|
98
|
+
* write elsewhere, adds nothing.
|
|
99
|
+
*/
|
|
100
|
+
function checkAfterWrite(root, { payload, cli }) {
|
|
101
|
+
const input = payload.tool_input ?? {};
|
|
102
|
+
let hub = root;
|
|
103
|
+
let written = null;
|
|
104
|
+
if (payload.tool_name === "Bash") {
|
|
105
|
+
if (!input.command?.includes(".mainflow")) return null;
|
|
106
|
+
} else {
|
|
107
|
+
if (!input.file_path) return null;
|
|
108
|
+
const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? root, input.file_path);
|
|
109
|
+
const owner = hubOfFile(file);
|
|
110
|
+
if (!owner) return null;
|
|
111
|
+
hub = owner;
|
|
112
|
+
written = relative(owner, file);
|
|
113
|
+
}
|
|
114
|
+
let problems;
|
|
115
|
+
try {
|
|
116
|
+
problems = loadHub(hub).problems;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
problems = error instanceof HubError ? [...error.problems, {
|
|
119
|
+
file: `${DATA_DIR}/config.yaml`,
|
|
120
|
+
field: "",
|
|
121
|
+
message: error.message
|
|
122
|
+
}] : [{
|
|
123
|
+
file: DATA_DIR,
|
|
124
|
+
field: "",
|
|
125
|
+
message: error.message
|
|
126
|
+
}];
|
|
127
|
+
}
|
|
128
|
+
if (problems.length === 0) return null;
|
|
129
|
+
const own = written ? problems.filter((p) => p.file === written) : [];
|
|
130
|
+
const others = problems.filter((p) => !own.includes(p));
|
|
131
|
+
const lines = [
|
|
132
|
+
written && own.length ? `[mainflow] 剛寫入的 ${written} 有 ${own.length} 個問題,改好再繼續:` : `[mainflow] 寫入 .mainflow/ 後,check 找到 ${problems.length} 個問題:`,
|
|
133
|
+
...own.map(problemLine),
|
|
134
|
+
...others.length ? [...own.length ? [`其他檔案還有 ${others.length} 個問題:`] : [], ...others.slice(0, OTHER_PROBLEMS_SHOWN).map(problemLine)] : [],
|
|
135
|
+
...others.length > OTHER_PROBLEMS_SHOWN ? [`還有 ${others.length - OTHER_PROBLEMS_SHOWN} 個沒列出,跑 ${cli} check 看全部。`] : []
|
|
136
|
+
];
|
|
137
|
+
return JSON.stringify({ hookSpecificOutput: {
|
|
138
|
+
hookEventName: "PostToolUse",
|
|
139
|
+
additionalContext: lines.join("\n")
|
|
140
|
+
} });
|
|
141
|
+
}
|
|
142
|
+
const HOOK_PLATFORMS = { "claude-code": CLAUDE_CODE_EVENTS };
|
|
143
|
+
/**
|
|
144
|
+
* Run a hook handler. A hook must never break the host session, so outside a hub it stays silent and any
|
|
145
|
+
* failure is reported on stderr while the caller still exits 0.
|
|
146
|
+
*/
|
|
147
|
+
function runHook(platform, event, ctx) {
|
|
148
|
+
const handler = HOOK_PLATFORMS[platform]?.[event];
|
|
149
|
+
if (!handler) return {
|
|
150
|
+
stdout: null,
|
|
151
|
+
error: `unknown hook ${platform} ${event}; known: ${Object.entries(HOOK_PLATFORMS).flatMap(([p, e]) => Object.keys(e).map((k) => `${p} ${k}`)).join(", ")}`
|
|
152
|
+
};
|
|
153
|
+
const root = findHubRoot(ctx.payload.cwd ?? process.cwd());
|
|
154
|
+
if (!root) return {
|
|
155
|
+
stdout: null,
|
|
156
|
+
error: null
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
return {
|
|
160
|
+
stdout: handler(root, ctx),
|
|
161
|
+
error: null
|
|
162
|
+
};
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
stdout: null,
|
|
166
|
+
error: `mainflow hook ${platform} ${event}: ${error.message}`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
export { HOOK_PLATFORMS, claudeCodeSessionId, runHook, subagentTranscript };
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/host.d.ts
|
|
2
|
+
export declare const DEFAULT_UI_PORT = 4320;
|
|
3
|
+
export declare const hostName: (hub: string) => string;
|
|
4
|
+
export declare function siteBlock(hub: string, port: number): string;
|
|
5
|
+
/** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
|
|
6
|
+
export declare function caddySites(): string | null;
|
|
7
|
+
export interface HostOptions {
|
|
8
|
+
reload?: (sites: string) => string | null;
|
|
9
|
+
running?: () => Promise<boolean>;
|
|
10
|
+
}
|
|
11
|
+
/** The host name URL when Caddy serves it, or why it does not so the caller can fall back to localhost. */
|
|
12
|
+
export type HostResult = {
|
|
13
|
+
url: string;
|
|
14
|
+
} | {
|
|
15
|
+
url: null;
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
|
|
20
|
+
* port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
|
|
21
|
+
* different hub of the same name stops here: overwriting it would silently take that hub's host name.
|
|
22
|
+
*/
|
|
23
|
+
export declare function syncHost(root: string, hub: string, port: number, options?: HostOptions): Promise<HostResult>;
|
|
24
|
+
//#endregion
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { G as DATA_DIR, W as WriteError } from "./dist-BktkzRuK.js";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
//#region src/host.ts
|
|
6
|
+
const DEFAULT_UI_PORT = 4320;
|
|
7
|
+
const hostName = (hub) => `${hub}.mainflow.localhost`;
|
|
8
|
+
function siteBlock(hub, port) {
|
|
9
|
+
return [
|
|
10
|
+
`# mainflow hub ${hub}: written by \`mainflow ui\` to match the port it serves on; do not edit.`,
|
|
11
|
+
`${hostName(hub)} {`,
|
|
12
|
+
" tls internal",
|
|
13
|
+
`\treverse_proxy localhost:${port}`,
|
|
14
|
+
"}",
|
|
15
|
+
""
|
|
16
|
+
].join("\n");
|
|
17
|
+
}
|
|
18
|
+
/** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
|
|
19
|
+
function caddySites() {
|
|
20
|
+
if (process.env.MAINFLOW_CADDY_SITES) return process.env.MAINFLOW_CADDY_SITES;
|
|
21
|
+
const prefix = spawnSync("brew", ["--prefix"], { encoding: "utf8" });
|
|
22
|
+
if (prefix.error || prefix.status !== 0) return null;
|
|
23
|
+
return join(prefix.stdout.trim(), "etc/caddy/sites");
|
|
24
|
+
}
|
|
25
|
+
/** Reload Caddy from the Caddyfile two levels above the sites directory; the error text on failure. */
|
|
26
|
+
function reloadCaddy(sites) {
|
|
27
|
+
const caddyfile = join(sites, "..", "..", "Caddyfile");
|
|
28
|
+
const reload = spawnSync("caddy", [
|
|
29
|
+
"reload",
|
|
30
|
+
"--config",
|
|
31
|
+
caddyfile
|
|
32
|
+
], { encoding: "utf8" });
|
|
33
|
+
if (reload.error) return reload.error.message;
|
|
34
|
+
return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
|
|
38
|
+
* Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
|
|
39
|
+
* refused connection or a timeout means Caddy is down (observed 2026-09-25: checking for 200 reported a
|
|
40
|
+
* running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
|
|
41
|
+
*/
|
|
42
|
+
async function caddyRunning() {
|
|
43
|
+
const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
|
|
44
|
+
try {
|
|
45
|
+
await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
|
|
52
|
+
/**
|
|
53
|
+
* Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
|
|
54
|
+
* port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
|
|
55
|
+
* different hub of the same name stops here: overwriting it would silently take that hub's host name.
|
|
56
|
+
*/
|
|
57
|
+
async function syncHost(root, hub, port, options = {}) {
|
|
58
|
+
const sites = caddySites();
|
|
59
|
+
if (!sites || !existsSync(sites)) return {
|
|
60
|
+
url: null,
|
|
61
|
+
reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
|
|
62
|
+
};
|
|
63
|
+
const dir = join(root, DATA_DIR, "local", "caddy");
|
|
64
|
+
const file = join(dir, `${hub}.caddy`);
|
|
65
|
+
const link = join(sites, `mainflow-${hub}.caddy`);
|
|
66
|
+
const linked = lstatSync(link, { throwIfNoEntry: false });
|
|
67
|
+
if (linked) {
|
|
68
|
+
const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
|
|
69
|
+
if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
|
|
70
|
+
}
|
|
71
|
+
const block = siteBlock(hub, port);
|
|
72
|
+
if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
writeFileSync(file, block);
|
|
75
|
+
if (linked) rmSync(link);
|
|
76
|
+
symlinkSync(file, link);
|
|
77
|
+
const failure = (options.reload ?? reloadCaddy)(sites);
|
|
78
|
+
if (failure) return {
|
|
79
|
+
url: null,
|
|
80
|
+
reason: `caddy reload failed: ${failure}`
|
|
81
|
+
};
|
|
82
|
+
} else if (!await (options.running ?? caddyRunning)()) return {
|
|
83
|
+
url: null,
|
|
84
|
+
reason: "Caddy is not running; start it with `brew services start caddy`"
|
|
85
|
+
};
|
|
86
|
+
return { url: `https://${hostName(hub)}` };
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
export { DEFAULT_UI_PORT, caddySites, hostName, siteBlock, syncHost };
|